diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json deleted file mode 100644 index 539aef5e..00000000 --- a/.devcontainer/devcontainer.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "GhostClass DevContainer", - "context": "..", - "dockerFile": "Dockerfile", - "build": { - "args": {} - }, - "workspaceFolder": "/workspace", - "runArgs": ["--init"], - "remoteUser": "vscode" -} diff --git a/.example.env b/.example.env index 96ce20d2..22524e88 100644 --- a/.example.env +++ b/.example.env @@ -44,11 +44,7 @@ NEXT_PUBLIC_APP_NAME=GhostClass # âš ī¸ App version displayed in footer and health checks # 🔨 Build-time (Infisical `/build-time` folder) -NEXT_PUBLIC_APP_VERSION=4.3.6 - -# âš ī¸ Minimum supported app version required to bypass forced update -# 🚀 Runtime (Infisical `/runtime` folder → Server Env Var) -MIN_APP_VERSION=4.3.6 +NEXT_PUBLIC_APP_VERSION=4.3.7 # âš ī¸ Your production domain WITHOUT https:// # All URL-based variables are derived from this. @@ -234,7 +230,7 @@ NEXT_PUBLIC_ENABLE_SW_IN_DEV=false # ---------------------------------------------------------------------------- # â„šī¸ Android Package Name (e.g., com.ghostclass.app) -# 🔨 Build-time / 🚀 Runtime (Infisical `/build-time` folder / Runtime variable) +# 🔨 Build-time (Infisical `/build-time` folder ) NEXT_PUBLIC_ANDROID_PACKAGE_NAME=com.devakesu.apps.ghostclass # âš ī¸ Base64-encoded google-services.json file for Firebase / Google Services Plugin @@ -361,6 +357,10 @@ JWE_PRIVATE_KEY= # Mobile Security & Attestation (Zero-Trust) # ---------------------------------------------------------------------------- +# âš ī¸ Minimum supported app version required to bypass forced update +# 🚀 Runtime (Infisical `/runtime` folder → Server Env Var) +MIN_APP_VERSION=4.3.7 + # â„šī¸ Enforce Firebase App Check for all mobile clients in production # Valid: "true", "false" (default: false in dev, true recommended in prod) # 🚀 Runtime (Infisical `/runtime` folder → Server Env Var) @@ -563,7 +563,7 @@ ALLOW_APP_DOMAIN_LOCALHOST_FALLBACK=false # 🔑 CI/CD (Infisical `/ci` folder → GitHub CI/CD) CODECOV_TOKEN= -# ── Supabase migration deployment (deploy-supabase.yaml) ──────────────────── +# ── Deployment workflow (deploy.yml) ──────────────────────────────────────── # 🔑 CI/CD (Infisical `/ci` folder → GitHub CI/CD) SUPABASE_ACCESS_TOKEN= SUPABASE_DB_PASSWORD= diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml deleted file mode 100644 index e5c84b14..00000000 --- a/.github/workflows/deploy-prod.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Deploy to Production - -on: - workflow_dispatch: - -permissions: {} - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" - -jobs: - deploy-to-production: - name: Deploy to Production - runs-on: ubuntu-latest - steps: - - name: Trigger Coolify deployment - env: - COOLIFY_BASE_URL: ${{ secrets.COOLIFY_BASE_URL }} - COOLIFY_APP_ID: ${{ secrets.COOLIFY_APP_ID }} - COOLIFY_API_TOKEN: ${{ secrets.COOLIFY_API_TOKEN }} - run: | - set -euo pipefail - - # Validate required Coolify configuration - : "${COOLIFY_BASE_URL:?COOLIFY_BASE_URL secret is required}" - : "${COOLIFY_APP_ID:?COOLIFY_APP_ID secret is required}" - : "${COOLIFY_API_TOKEN:?COOLIFY_API_TOKEN secret is required}" - - [ -n "$COOLIFY_API_TOKEN" ] && echo "::add-mask::$COOLIFY_API_TOKEN" - [ -n "$COOLIFY_BASE_URL" ] && echo "::add-mask::$COOLIFY_BASE_URL" - [ -n "$COOLIFY_APP_ID" ] && echo "::add-mask::$COOLIFY_APP_ID" - - VERSION="${{ env.NEXT_PUBLIC_APP_VERSION || vars.NEXT_PUBLIC_APP_VERSION || 'latest' }}" - echo "Deploying version ${VERSION} to Coolify" - echo "Note: Ensure Coolify is configured to pull the versioned tag (e.g., ${VERSION})" - - curl --fail-with-body --silent --show-error \ - "$COOLIFY_BASE_URL/api/v1/deploy?uuid=$COOLIFY_APP_ID" \ - -H "Authorization: Bearer $COOLIFY_API_TOKEN" - - echo "✓ Deployment triggered successfully" diff --git a/.github/workflows/deploy-supabase.yaml b/.github/workflows/deploy-supabase.yaml deleted file mode 100644 index 85ce95ec..00000000 --- a/.github/workflows/deploy-supabase.yaml +++ /dev/null @@ -1,53 +0,0 @@ -name: Deploy Supabase - -permissions: {} - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" - -on: - workflow_dispatch: # Allows manual triggering if GitHub path filtering skips a commit - push: - branches: - - main - paths: - - 'supabase/**' - -jobs: - deploy: - runs-on: ubuntu-latest - permissions: - contents: read - env: - INFISICAL_CLIENT_ID: ${{ secrets.INFISICAL_CLIENT_ID || '' }} - INFISICAL_CLIENT_SECRET: ${{ secrets.INFISICAL_CLIENT_SECRET || '' }} - - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - - - name: Fetch CI secrets from Infisical - if: env.INFISICAL_CLIENT_ID != '' && env.INFISICAL_CLIENT_SECRET != '' - uses: infisical/secrets-action@77ab1f4ccd183a543cb5b42435fbd181189f4995 - with: - client-id: ${{ env.INFISICAL_CLIENT_ID }} - client-secret: ${{ env.INFISICAL_CLIENT_SECRET }} - project-slug: ${{ secrets.INFISICAL_PROJECT_SLUG || secrets.INFISICAL_PROJECT_ID }} - env-slug: "prod" - secret-path: "/ci" - continue-on-error: true - - - uses: supabase/setup-cli@df56b21da46c98abb12a9804e4fb1f657773e333 - with: - version: 2.98.2 - - - name: Link Project - env: - SUPABASE_PROJECT_ID: ${{ env.SUPABASE_PROJECT_ID || secrets.SUPABASE_PROJECT_ID }} - run: supabase link --project-ref $SUPABASE_PROJECT_ID - - - name: Deploy Database Changes (Migrations) - env: - SUPABASE_ACCESS_TOKEN: ${{ env.SUPABASE_ACCESS_TOKEN || secrets.SUPABASE_ACCESS_TOKEN }} - SUPABASE_DB_PASSWORD: ${{ env.SUPABASE_DB_PASSWORD || secrets.SUPABASE_DB_PASSWORD }} - SUPABASE_PROJECT_ID: ${{ env.SUPABASE_PROJECT_ID || secrets.SUPABASE_PROJECT_ID }} - run: supabase db push diff --git a/.github/workflows/deploy-egress-proxies.yml b/.github/workflows/deploy.yml similarity index 70% rename from .github/workflows/deploy-egress-proxies.yml rename to .github/workflows/deploy.yml index b509425e..6c6ca435 100644 --- a/.github/workflows/deploy-egress-proxies.yml +++ b/.github/workflows/deploy.yml @@ -1,27 +1,69 @@ -name: Deploy Egress Proxies +name: Deploy on: - # Auto-run when worker source files change on main (like deploy-supabase.yaml) - push: - branches: - - main - paths: - - "workers/**" - # Allow manual deployment/testing workflow_dispatch: +permissions: {} + env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" -permissions: {} - jobs: - deploy-cloudflare-worker: - name: Deploy Cloudflare Worker + # ──────────────────────────────────────────────────────────────────────────── + # Job 1: Deploy Supabase (Migrations) + # ──────────────────────────────────────────────────────────────────────────── + deploy-supabase: + name: Deploy Supabase + runs-on: ubuntu-latest + permissions: + contents: read + env: + INFISICAL_CLIENT_ID: ${{ secrets.INFISICAL_CLIENT_ID || '' }} + INFISICAL_CLIENT_SECRET: ${{ secrets.INFISICAL_CLIENT_SECRET || '' }} + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + + - name: Fetch CI secrets from Infisical + if: env.INFISICAL_CLIENT_ID != '' && env.INFISICAL_CLIENT_SECRET != '' + uses: infisical/secrets-action@77ab1f4ccd183a543cb5b42435fbd181189f4995 + with: + client-id: ${{ env.INFISICAL_CLIENT_ID }} + client-secret: ${{ env.INFISICAL_CLIENT_SECRET }} + project-slug: ${{ secrets.INFISICAL_PROJECT_SLUG || secrets.INFISICAL_PROJECT_ID }} + env-slug: "prod" + secret-path: "/ci" + continue-on-error: true + + - name: Setup Supabase CLI + uses: supabase/setup-cli@df56b21da46c98abb12a9804e4fb1f657773e333 + with: + version: 2.98.2 + + - name: Link Project + env: + SUPABASE_PROJECT_ID: ${{ env.SUPABASE_PROJECT_ID || secrets.SUPABASE_PROJECT_ID }} + run: supabase link --project-ref $SUPABASE_PROJECT_ID + + - name: Deploy Database Changes (Migrations) + env: + SUPABASE_ACCESS_TOKEN: ${{ env.SUPABASE_ACCESS_TOKEN || secrets.SUPABASE_ACCESS_TOKEN }} + SUPABASE_DB_PASSWORD: ${{ env.SUPABASE_DB_PASSWORD || secrets.SUPABASE_DB_PASSWORD }} + SUPABASE_PROJECT_ID: ${{ env.SUPABASE_PROJECT_ID || secrets.SUPABASE_PROJECT_ID }} + run: supabase db push + + # ──────────────────────────────────────────────────────────────────────────── + # Job 2: Deploy Proxies (Cloudflare Workers & AWS Lambdas) + # ──────────────────────────────────────────────────────────────────────────── + deploy-proxy: + name: Deploy Proxy + needs: deploy-supabase if: github.actor != 'dependabot[bot]' runs-on: ubuntu-latest permissions: contents: read + steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd @@ -31,8 +73,9 @@ jobs: with: node-version: "24.14.1" + # ── Cloudflare Worker Egress Proxy ── - name: Validate Cloudflare deploy secrets - id: validate + id: validate-cf env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} @@ -49,14 +92,11 @@ jobs: fi - name: Install Wrangler - if: steps.validate.outputs.skip != 'true' - # workers/package-lock.json pins wrangler@4.59.1 with full dependency - # integrity hashes (SHA-512 SRI). npm ci verifies all hashes before - # installation, preventing supply-chain substitution attacks. + if: steps.validate-cf.outputs.skip != 'true' run: npm ci --prefix workers - name: Update Cloudflare Worker secret (PROXY_SECRET) - if: steps.validate.outputs.skip != 'true' + if: steps.validate-cf.outputs.skip != 'true' env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} @@ -67,7 +107,7 @@ jobs: printf "%s" "${CF_PROXY_SECRET}" | ./workers/node_modules/.bin/wrangler secret put PROXY_SECRET --name "${CF_WORKER_NAME}" - name: Deploy Cloudflare Worker - if: steps.validate.outputs.skip != 'true' + if: steps.validate-cf.outputs.skip != 'true' env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} @@ -75,26 +115,54 @@ jobs: NEXT_PUBLIC_BACKEND_URL: ${{ vars.NEXT_PUBLIC_BACKEND_URL }} run: | set -euo pipefail - # Evaluate the commit date in UTC so Cloudflare (UTC) never sees a future date - # (commits made in east-of-UTC timezones would otherwise record tomorrow's date) COMPAT_DATE=$(TZ=UTC git log -1 --format=%cd --date=format-local:'%Y-%m-%d' "${{ github.sha }}") ./workers/node_modules/.bin/wrangler deploy workers/ezygo-proxy/index.js \ --name "${CF_WORKER_NAME}" \ --var "EZYGO_API_URL:${NEXT_PUBLIC_BACKEND_URL}" \ --compatibility-date "${COMPAT_DATE}" - deploy-aws-lambda: - name: Deploy AWS Lambda Proxy - if: github.actor != 'dependabot[bot]' - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + # ── Supabase CF Worker Proxy ── + - name: Validate Supabase Cloudflare deploy secrets + id: validate-supabase-cf + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CF_SUPABASE_PROXY_WORKER_NAME: ${{ vars.CF_SUPABASE_PROXY_WORKER_NAME }} + NEXT_PUBLIC_SUPABASE_URL: ${{ vars.NEXT_PUBLIC_SUPABASE_URL }} + NEXT_PUBLIC_APP_DOMAIN: ${{ vars.NEXT_PUBLIC_APP_DOMAIN }} + run: | + if [ -z "${CLOUDFLARE_API_TOKEN}" ] || [ -z "${CLOUDFLARE_ACCOUNT_ID}" ] || [ -z "${CF_SUPABASE_PROXY_WORKER_NAME}" ] || [ -z "${NEXT_PUBLIC_SUPABASE_URL}" ] || [ -z "${NEXT_PUBLIC_APP_DOMAIN}" ]; then + echo "::warning::Skipping Supabase CF Worker deploy because required credentials/vars are missing." + echo "Required: secrets.CLOUDFLARE_API_TOKEN, secrets.CLOUDFLARE_ACCOUNT_ID, vars.CF_SUPABASE_PROXY_WORKER_NAME, vars.NEXT_PUBLIC_SUPABASE_URL, vars.NEXT_PUBLIC_APP_DOMAIN" + echo "skip=true" >> "${GITHUB_OUTPUT}" + else + echo "skip=false" >> "${GITHUB_OUTPUT}" + fi + - name: Install Wrangler (Supabase CF Worker) + if: steps.validate-supabase-cf.outputs.skip != 'true' && steps.validate-cf.outputs.skip == 'true' + run: npm ci --prefix workers + + - name: Deploy Supabase CF Worker + if: steps.validate-supabase-cf.outputs.skip != 'true' + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CF_SUPABASE_PROXY_WORKER_NAME: ${{ vars.CF_SUPABASE_PROXY_WORKER_NAME }} + NEXT_PUBLIC_SUPABASE_URL: ${{ vars.NEXT_PUBLIC_SUPABASE_URL }} + NEXT_PUBLIC_APP_DOMAIN: ${{ vars.NEXT_PUBLIC_APP_DOMAIN }} + run: | + set -euo pipefail + COMPAT_DATE=$(TZ=UTC git log -1 --format=%cd --date=format-local:'%Y-%m-%d' "${{ github.sha }}") + ./workers/node_modules/.bin/wrangler deploy workers/supabase-proxy/index.js \ + --name "${CF_SUPABASE_PROXY_WORKER_NAME}" \ + --var "SUPABASE_URL:${NEXT_PUBLIC_SUPABASE_URL}" \ + --var "ALLOWED_ORIGIN:https://${NEXT_PUBLIC_APP_DOMAIN}" \ + --compatibility-date "${COMPAT_DATE}" + + # ── AWS Lambda Proxy ── - name: Validate AWS deploy secrets - id: validate + id: validate-aws env: AWS_REGION: ${{ vars.AWS_REGION }} AWS_LAMBDA_FUNCTION_NAME: ${{ vars.AWS_LAMBDA_FUNCTION_NAME }} @@ -112,7 +180,7 @@ jobs: fi - name: Configure AWS credentials - if: steps.validate.outputs.skip != 'true' + if: steps.validate-aws.outputs.skip != 'true' uses: aws-actions/configure-aws-credentials@713aaabfecea015b1ad8d37dc2b819ee41c00eb0 with: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} @@ -120,7 +188,7 @@ jobs: aws-region: ${{ vars.AWS_REGION }} - name: Update Lambda environment variables - if: steps.validate.outputs.skip != 'true' + if: steps.validate-aws.outputs.skip != 'true' env: AWS_LAMBDA_FUNCTION_NAME: ${{ vars.AWS_LAMBDA_FUNCTION_NAME }} NEXT_PUBLIC_BACKEND_URL: ${{ vars.NEXT_PUBLIC_BACKEND_URL }} @@ -146,14 +214,14 @@ jobs: aws lambda wait function-updated --function-name "${AWS_LAMBDA_FUNCTION_NAME}" - name: Package Lambda source - if: steps.validate.outputs.skip != 'true' + if: steps.validate-aws.outputs.skip != 'true' run: | set -euo pipefail cd workers/ezygo-proxy-aws zip -q -r "${GITHUB_WORKSPACE}/ezygo-proxy-aws.zip" index.mjs - name: Deploy Lambda function code - if: steps.validate.outputs.skip != 'true' + if: steps.validate-aws.outputs.skip != 'true' env: AWS_LAMBDA_FUNCTION_NAME: ${{ vars.AWS_LAMBDA_FUNCTION_NAME }} run: | @@ -164,79 +232,9 @@ jobs: --zip-file "fileb://${GITHUB_WORKSPACE}/ezygo-proxy-aws.zip" \ --publish - # ──────────────────────────────────────────────────────────────────────────── - # Supabase ISP-block bypass proxy — Cloudflare Worker (Tier 1) - # ──────────────────────────────────────────────────────────────────────────── - deploy-supabase-cf-proxy: - name: Deploy Supabase CF Worker Proxy - if: github.actor != 'dependabot[bot]' - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - - - name: Setup Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e - with: - node-version: "24.14.1" - - - name: Validate Cloudflare deploy secrets - id: validate - env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - CF_SUPABASE_PROXY_WORKER_NAME: ${{ vars.CF_SUPABASE_PROXY_WORKER_NAME }} - NEXT_PUBLIC_SUPABASE_URL: ${{ vars.NEXT_PUBLIC_SUPABASE_URL }} - NEXT_PUBLIC_APP_DOMAIN: ${{ vars.NEXT_PUBLIC_APP_DOMAIN }} - run: | - if [ -z "${CLOUDFLARE_API_TOKEN}" ] || [ -z "${CLOUDFLARE_ACCOUNT_ID}" ] || [ -z "${CF_SUPABASE_PROXY_WORKER_NAME}" ] || [ -z "${NEXT_PUBLIC_SUPABASE_URL}" ] || [ -z "${NEXT_PUBLIC_APP_DOMAIN}" ]; then - echo "::warning::Skipping Supabase CF Worker deploy because required credentials/vars are missing." - echo "Required: secrets.CLOUDFLARE_API_TOKEN, secrets.CLOUDFLARE_ACCOUNT_ID, vars.CF_SUPABASE_PROXY_WORKER_NAME, vars.NEXT_PUBLIC_SUPABASE_URL, vars.NEXT_PUBLIC_APP_DOMAIN" - echo "skip=true" >> "${GITHUB_OUTPUT}" - else - echo "skip=false" >> "${GITHUB_OUTPUT}" - fi - - - name: Install Wrangler - if: steps.validate.outputs.skip != 'true' - run: npm ci --prefix workers - - - name: Deploy Supabase CF Worker - if: steps.validate.outputs.skip != 'true' - env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - CF_SUPABASE_PROXY_WORKER_NAME: ${{ vars.CF_SUPABASE_PROXY_WORKER_NAME }} - NEXT_PUBLIC_SUPABASE_URL: ${{ vars.NEXT_PUBLIC_SUPABASE_URL }} - NEXT_PUBLIC_APP_DOMAIN: ${{ vars.NEXT_PUBLIC_APP_DOMAIN }} - run: | - set -euo pipefail - # Evaluate the commit date in UTC so Cloudflare (UTC) never sees a future date - # (commits made in east-of-UTC timezones would otherwise record tomorrow's date) - COMPAT_DATE=$(TZ=UTC git log -1 --format=%cd --date=format-local:'%Y-%m-%d' "${{ github.sha }}") - ./workers/node_modules/.bin/wrangler deploy workers/supabase-proxy/index.js \ - --name "${CF_SUPABASE_PROXY_WORKER_NAME}" \ - --var "SUPABASE_URL:${NEXT_PUBLIC_SUPABASE_URL}" \ - --var "ALLOWED_ORIGIN:https://${NEXT_PUBLIC_APP_DOMAIN}" \ - --compatibility-date "${COMPAT_DATE}" - - # ──────────────────────────────────────────────────────────────────────────── - # Supabase ISP-block bypass proxy — AWS Lambda (Tier 2 / fallback) - # ──────────────────────────────────────────────────────────────────────────── - deploy-supabase-aws-proxy: - name: Deploy Supabase AWS Lambda Proxy - if: github.actor != 'dependabot[bot]' - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - - - name: Validate AWS deploy secrets - id: validate + # ── Supabase AWS Lambda Proxy ── + - name: Validate Supabase AWS deploy secrets + id: validate-supabase-aws env: AWS_REGION: ${{ vars.AWS_REGION }} AWS_SUPABASE_LAMBDA_FUNCTION_NAME: ${{ vars.AWS_SUPABASE_LAMBDA_FUNCTION_NAME }} @@ -253,16 +251,16 @@ jobs: echo "skip=false" >> "${GITHUB_OUTPUT}" fi - - name: Configure AWS credentials - if: steps.validate.outputs.skip != 'true' + - name: Configure AWS credentials (Supabase AWS Lambda) + if: steps.validate-supabase-aws.outputs.skip != 'true' && steps.validate-aws.outputs.skip == 'true' uses: aws-actions/configure-aws-credentials@713aaabfecea015b1ad8d37dc2b819ee41c00eb0 with: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-region: ${{ vars.AWS_REGION }} - - name: Update Lambda environment variables - if: steps.validate.outputs.skip != 'true' + - name: Update Lambda environment variables (Supabase AWS Lambda) + if: steps.validate-supabase-aws.outputs.skip != 'true' env: AWS_SUPABASE_LAMBDA_FUNCTION_NAME: ${{ vars.AWS_SUPABASE_LAMBDA_FUNCTION_NAME }} NEXT_PUBLIC_SUPABASE_URL: ${{ vars.NEXT_PUBLIC_SUPABASE_URL }} @@ -287,15 +285,15 @@ jobs: aws lambda wait function-updated --function-name "${AWS_SUPABASE_LAMBDA_FUNCTION_NAME}" - - name: Package Lambda source - if: steps.validate.outputs.skip != 'true' + - name: Package Lambda source (Supabase AWS Lambda) + if: steps.validate-supabase-aws.outputs.skip != 'true' run: | set -euo pipefail cd workers/supabase-proxy-aws zip -q -r "${GITHUB_WORKSPACE}/supabase-proxy-aws.zip" index.mjs - - name: Deploy Lambda function code - if: steps.validate.outputs.skip != 'true' + - name: Deploy Lambda function code (Supabase AWS Lambda) + if: steps.validate-supabase-aws.outputs.skip != 'true' env: AWS_SUPABASE_LAMBDA_FUNCTION_NAME: ${{ vars.AWS_SUPABASE_LAMBDA_FUNCTION_NAME }} run: | @@ -305,3 +303,40 @@ jobs: --function-name "${AWS_SUPABASE_LAMBDA_FUNCTION_NAME}" \ --zip-file "fileb://${GITHUB_WORKSPACE}/supabase-proxy-aws.zip" \ --publish + + # ──────────────────────────────────────────────────────────────────────────── + # Job 3: Deploy to Production (Coolify Trigger) + # ──────────────────────────────────────────────────────────────────────────── + deploy-prod: + name: Deploy Production + needs: deploy-proxy + runs-on: ubuntu-latest + permissions: {} + + steps: + - name: Trigger Coolify deployment + env: + COOLIFY_BASE_URL: ${{ secrets.COOLIFY_BASE_URL }} + COOLIFY_APP_ID: ${{ secrets.COOLIFY_APP_ID }} + COOLIFY_API_TOKEN: ${{ secrets.COOLIFY_API_TOKEN }} + run: | + set -euo pipefail + + # Validate required Coolify configuration + : "${COOLIFY_BASE_URL:?COOLIFY_BASE_URL secret is required}" + : "${COOLIFY_APP_ID:?COOLIFY_APP_ID secret is required}" + : "${COOLIFY_API_TOKEN:?COOLIFY_API_TOKEN secret is required}" + + [ -n "$COOLIFY_API_TOKEN" ] && echo "::add-mask::$COOLIFY_API_TOKEN" + [ -n "$COOLIFY_BASE_URL" ] && echo "::add-mask::$COOLIFY_BASE_URL" + [ -n "$COOLIFY_APP_ID" ] && echo "::add-mask::$COOLIFY_APP_ID" + + VERSION="${{ env.NEXT_PUBLIC_APP_VERSION || vars.NEXT_PUBLIC_APP_VERSION || 'latest' }}" + echo "Deploying version ${VERSION} to Coolify" + echo "Note: Ensure Coolify is configured to pull the versioned tag (e.g., ${VERSION})" + + curl --fail-with-body --silent --show-error \ + "$COOLIFY_BASE_URL/api/v1/deploy?uuid=$COOLIFY_APP_ID" \ + -H "Authorization: Bearer $COOLIFY_API_TOKEN" + + echo "✓ Deployment triggered successfully" diff --git a/.vscode/extensions.json b/.vscode/extensions.json deleted file mode 100644 index 74baffcc..00000000 --- a/.vscode/extensions.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "recommendations": ["denoland.vscode-deno"] -} diff --git a/.vscode/mcp.json b/.vscode/mcp.json deleted file mode 100644 index 3cdb7b4a..00000000 --- a/.vscode/mcp.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "servers": { - "Sentry": { - "url": "https://mcp.sentry.dev/mcp/devanarayanan/javascript-nextjs", - "type": "http" - } - } -} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 617f77a7..00000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "deno.enablePaths": [ - "supabase/functions" - ], - "deno.lint": true, - "deno.unstable": [ - "bare-node-builtins", - "byonm", - "sloppy-imports", - "unsafe-proto", - "webgpu", - "broadcast-channel", - "worker-options", - "cron", - "kv", - "ffi", - "fs", - "http", - "net" - ], - "[typescript]": { - "editor.defaultFormatter": "denoland.vscode-deno" - }, - "css.lint.unknownAtRules": "ignore", - "scss.lint.unknownAtRules": "ignore", - "less.lint.unknownAtRules": "ignore", - "markdownlint.ignore": [ - "LICENSE" - ], - "java.configuration.updateBuildConfiguration": "automatic", - "dart.env": { - "PUB_CACHE": "mobile/.pub-cache" - }, - - "chat.tools.terminal.autoApprove": { - "flutter": true - }, - - "explorer.autoReveal": true, - "explorer.incrementalNaming": "smart", - "workbench.editor.restoreViewState": true, - "workbench.startupEditor": "readme", - - "search.exclude": { - "**/Temp": true - }, - "files.watcherExclude": { - "**/Temp/**": true - } -} diff --git a/README.md b/README.md index 641a30fe..b34871bd 100644 --- a/README.md +++ b/README.md @@ -12,19 +12,19 @@

- Next.js - React - Tailwind + Next.js + React + Tailwind TypeScript

- Flutter + Flutter Android iOS

- Vitest - Playwright + Vitest + Playwright

@@ -54,14 +54,14 @@ GhostClass is the ultimate academic survival tool for students who want to manag ### Core Framework -- **Next.js 16.1.6** - React 19 with App Router +- **Next.js 16.2.6** - React 19 with App Router - **TypeScript 6.0.3** - Strict mode for type safety -- **Flutter 3.27+** - Cross-platform native mobile application +- **Flutter 3.44.0** - Cross-platform native mobile application - **Node.js** - v24.14.1+ ### Styling & UI -- **Tailwind CSS 4** - Utility-first styling with custom design system +- **Tailwind CSS 4.3.0** - Utility-first styling with custom design system - **Radix UI** - Accessible, unstyled component primitives - **Shadcn UI** - Beautiful pre-styled components - **Framer Motion** - Smooth animations and transitions @@ -141,7 +141,7 @@ For the full mathematical derivation, duty leave limits (5 per course), and pseu - **Node.js** - v24.14.1+ - **npm** - v11.11.0+ -- **Flutter SDK** - 3.27+ +- **Flutter SDK** - 3.44.0 - **Docker** - For containerized deployment (optional) ### Quick Start (Web) @@ -153,7 +153,7 @@ For the full mathematical derivation, duty leave limits (5 per course), and pseu ### Quick Start (Mobile) -1. **Install Flutter**: Ensure Flutter SDK 3.27+ is installed. +1. **Install Flutter**: Ensure Flutter SDK 3.44.0 is installed. 2. **Setup**: Navigate to `mobile/` and run `flutter pub get`. 3. **Secrets**: Copy `app_secrets.dart.example` to `app_secrets.dart` and fill your API keys. 4. **Run**: Connect a device and run `flutter run`. diff --git a/mobile/README.md b/mobile/README.md index a1ef8f77..15f4a5c2 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -1,7 +1,7 @@ # GhostClass Mobile -![Flutter](https://img.shields.io/badge/Flutter-3.27+-02569B?style=for-the-badge&logo=flutter&logoColor=white) -![Dart](https://img.shields.io/badge/Dart-^3.11.4-0175C2?style=for-the-badge&logo=dart&logoColor=white) +![Flutter](https://img.shields.io/badge/Flutter-3.44.0-02569B?style=for-the-badge&logo=flutter&logoColor=white) +![Dart](https://img.shields.io/badge/Dart-3.12.0-0175C2?style=for-the-badge&logo=dart&logoColor=white) ![Android](https://img.shields.io/badge/Android-10+-3DDC84?style=for-the-badge&logo=android&logoColor=black) ![iOS](https://img.shields.io/badge/iOS-13+-000000?style=for-the-badge&logo=apple&logoColor=white) ![License](https://img.shields.io/badge/License-GPL%20v3-blue?style=for-the-badge) @@ -29,8 +29,8 @@ GhostClass Mobile is a secure, zero-trust Flutter application that communicates | Package | Version | Purpose | | :--- | :--- | :--- | -| **Flutter** | 3.27+ | Cross-platform UI framework | -| **Dart** | ^3.11.4 | Language | +| **Flutter** | 3.44.0 | Cross-platform UI framework | +| **Dart** | 3.12.0 | Language | ### State Management @@ -200,8 +200,8 @@ mobile/ ### Prerequisites -- **Flutter SDK** — 3.27+ ([install](https://docs.flutter.dev/get-started/install)) -- **Dart SDK** — ^3.11.4 (bundled with Flutter) +- **Flutter SDK** — 3.44.0 ([install](https://docs.flutter.dev/get-started/install)) +- **Dart SDK** — 3.12.0 (bundled with Flutter) - **Android Studio / Xcode** — for emulator/simulator - **Firebase CLI** — for App Check configuration - **A GhostClass backend** — see the [root README](../README.md) for web setup diff --git a/mobile/lib/config/app_config.dart b/mobile/lib/config/app_config.dart index a17139b9..83d906af 100644 --- a/mobile/lib/config/app_config.dart +++ b/mobile/lib/config/app_config.dart @@ -75,7 +75,7 @@ class AppConfig { /// Current application version (derived from Infisical compilation injection). static String get appVersion => - const String.fromEnvironment('APP_VERSION', defaultValue: '4.3.6'); + const String.fromEnvironment('APP_VERSION', defaultValue: '4.3.7'); /// Commit SHA injected by CI for release builds. static String get appCommitSha => diff --git a/mobile/lib/logic/attendance_utils.dart b/mobile/lib/logic/attendance_utils.dart index 53364c0f..f73172f6 100644 --- a/mobile/lib/logic/attendance_utils.dart +++ b/mobile/lib/logic/attendance_utils.dart @@ -102,7 +102,8 @@ String normalizeDate(dynamic date) { if (RegExp(r'^\d+$').hasMatch(a) && RegExp(r'^\d+$').hasMatch(b) && RegExp(r'^\d+$').hasMatch(c)) { - final year = (a.length == 4) ? int.parse(a) : int.parse(c); + var year = (a.length == 4) ? int.parse(a) : int.parse(c); + if (year < 100) year += 2000; final month = int.parse(b); final day = (a.length == 4) ? int.parse(c) : int.parse(a); @@ -118,13 +119,10 @@ String normalizeDate(dynamic date) { return ''; } - if (a.length == 4) { - // YYYY-MM-DD - return "$a${b.padLeft(2, '0')}${c.padLeft(2, '0')}"; - } else if (c.length == 4) { - // DD-MM-YYYY - return "$c${b.padLeft(2, '0')}${a.padLeft(2, '0')}"; - } + final yearStr = year.toString().padLeft(4, '0'); + final monthStr = month.toString().padLeft(2, '0'); + final dayStr = day.toString().padLeft(2, '0'); + return '$yearStr$monthStr$dayStr'; } } } @@ -140,7 +138,8 @@ String normalizeDate(dynamic date) { if (RegExp(r'^\d+$').hasMatch(a) && RegExp(r'^\d+$').hasMatch(b) && RegExp(r'^\d+$').hasMatch(c)) { - final year = (a.length == 4) ? int.parse(a) : int.parse(c); + var year = (a.length == 4) ? int.parse(a) : int.parse(c); + if (year < 100) year += 2000; final month = int.parse(b); final day = (a.length == 4) ? int.parse(c) : int.parse(a); @@ -156,18 +155,15 @@ String normalizeDate(dynamic date) { return ''; } - if (a.length == 4) { - // YYYY/MM/DD - return "$a${b.padLeft(2, '0')}${c.padLeft(2, '0')}"; - } else { - // DD/MM/YYYY - return "$c${b.padLeft(2, '0')}${a.padLeft(2, '0')}"; - } + final yearStr = year.toString().padLeft(4, '0'); + final monthStr = month.toString().padLeft(2, '0'); + final dayStr = day.toString().padLeft(2, '0'); + return '$yearStr$monthStr$dayStr'; } } } - AppLogger.w( + AppLogger.e( 'attendance_utils.normalizeDate: Unrecognized date format. Returning empty string to avoid invalid slot keys.', {'raw': s}, ); @@ -370,6 +366,25 @@ String toTitleCase(String text) { .join(' '); } +String normalizePersonName(String text) { + return toTitleCase(text.trim().replaceAll(RegExp(r'\s+'), ' ')); +} + +bool isValidPersonName(String text) { + final trimmed = text.trim(); + if (trimmed.isEmpty) return false; + return RegExp(r"^[\p{L}\p{M}.'’\- ]+$", unicode: true).hasMatch(trimmed); +} + +bool isValidCourseName(String text) { + final trimmed = text.trim(); + if (trimmed.isEmpty) return false; + return RegExp( + r"^[\p{L}\p{M}\p{N}.'’&/()+,:;\- ]+$", + unicode: true, + ).hasMatch(trimmed); +} + String standardizeCourseCode(String input) { return input.trim().toUpperCase().replaceAll(RegExp(r'[\s\u00A0-]'), ''); } diff --git a/mobile/lib/logic/encrypted_value.dart b/mobile/lib/logic/encrypted_value.dart index 76ed68d4..50392e5f 100644 --- a/mobile/lib/logic/encrypted_value.dart +++ b/mobile/lib/logic/encrypted_value.dart @@ -57,6 +57,17 @@ class EncryptedValue { return Key(keyBytes); } + /// Overwrite entropy buffers to reduce risk of key reconstruction after logout. + /// Call this when the app is performing a full logout or memory wipe. + static void clearEntropy() { + for (var i = 0; i < _entropyA.length; i++) { + _entropyA[i] = 0; + } + for (var i = 0; i < _entropyB.length; i++) { + _entropyB[i] = 0; + } + } + /// Decrypts and returns the plaintext value. String get value { if (_encryptedBase64.isEmpty) return ''; diff --git a/mobile/lib/logic/error_utils.dart b/mobile/lib/logic/error_utils.dart index 0dced3e1..019fccd2 100644 --- a/mobile/lib/logic/error_utils.dart +++ b/mobile/lib/logic/error_utils.dart @@ -53,6 +53,19 @@ String formatApiError(dynamic response, String context) { final lower = message.toLowerCase(); + // 401 Unauthorized / Session Expired + if (status == 401 || + code == '401' || + lower.contains('unauthorized') || + lower.contains('unauthenticated')) { + return 'Session expired. Please log in again.'; + } + + // 403 Forbidden / Access Denied + if (status == 403 || code == '403' || lower.contains('forbidden')) { + return 'Access denied. Permission required.'; + } + // Supabase/Auth decoding errors (often due to proxy/ISP blocks or firewalls) if (lower.contains('failed to decode error response') || lower.contains('unexpected character') || @@ -127,10 +140,10 @@ String sanitizeTechnicalDetails(String error) { ); sanitized = sanitized.replaceAll(RegExp(r':\d{4,5}'), ':[REDACTED_PORT]'); - // Remove absolute Unix-like paths (keeping it safe for common startup logs) - sanitized = sanitized.replaceAll( - RegExp(r'\/[a-zA-Z0-9._\-\/]+\/[a-zA-Z0-9._\-]+'), - '[REDACTED_PATH]', + // Remove absolute Unix-like file paths, but leave URL paths intact. + sanitized = sanitized.replaceAllMapped( + RegExp(r'(^|[\s(\[\{<])\/(?:[A-Za-z0-9._\-]+\/)+[A-Za-z0-9._\-]+'), + (match) => '${match.group(1)}[REDACTED_PATH]', ); // Remove potential auth tokens in URLs or headers diff --git a/mobile/lib/logic/ezygo_batch_fetcher.dart b/mobile/lib/logic/ezygo_batch_fetcher.dart index 8a62288f..a73a25e3 100644 --- a/mobile/lib/logic/ezygo_batch_fetcher.dart +++ b/mobile/lib/logic/ezygo_batch_fetcher.dart @@ -62,7 +62,7 @@ class EzygoBatchFetcher { }) async { // Generate a unique cache key based on the request identity // We include method, path, token, and a hash of the body data to avoid collisions. - final dataKey = data != null ? json.encode(data) : ''; + final dataKey = data != null ? _encodeStableRequestData(data) : ''; final cacheKey = '$method|$path|$token|$dataKey'; final startGeneration = _generation; @@ -92,7 +92,7 @@ class EzygoBatchFetcher { if (_lastCircuitBreakerLog == null || now.difference(_lastCircuitBreakerLog!) > logThrottle) { _lastCircuitBreakerLog = now; - AppLogger.w( + AppLogger.e( 'EzygoBatchFetcher: CIRCUIT BREAKER ACTIVE. Blocking network request logic for $path', ); } else { @@ -272,12 +272,51 @@ class EzygoBatchFetcher { _inFlight.clear(); _setOutage(false); _generation++; + + // Reject all pending completers in the queue with a cancellation error + for (final completer in _queue) { + if (!completer.isCompleted) { + completer.completeError( + const AppException( + message: 'Ezygo fetch queue cleared.', + type: AppExceptionType.network, + ), + ); + } + } + _queue.clear(); + _activeRequests = 0; + AppLogger.i( - 'EzygoBatchFetcher: Cache and Outage state cleared. Generation updated to $_generation.', + 'EzygoBatchFetcher: Cache, Outage, Queue cleared & Requests reset. Generation updated to $_generation.', ); } } +String _encodeStableRequestData(dynamic data) { + dynamic normalize(dynamic value) { + if (value == null || value is num || value is bool || value is String) { + return value; + } + if (value is DateTime) return value.toIso8601String(); + if (value is Uri) return value.toString(); + if (value is Map) { + final entries = value.entries.toList() + ..sort((a, b) => a.key.toString().compareTo(b.key.toString())); + return { + for (final entry in entries) + entry.key.toString(): normalize(entry.value), + }; + } + if (value is Iterable) { + return value.map(normalize).toList(growable: false); + } + return value.toString(); + } + + return json.encode(normalize(data)); +} + class _CacheEntry { _CacheEntry({required this.response, required this.expiry}); final Response response; diff --git a/mobile/lib/logic/network_utils.dart b/mobile/lib/logic/network_utils.dart index 494a64a2..f3c0437e 100644 --- a/mobile/lib/logic/network_utils.dart +++ b/mobile/lib/logic/network_utils.dart @@ -57,7 +57,7 @@ class NetworkUtils { return true; } - AppLogger.w( + AppLogger.e( 'NetworkUtils: Certificate mismatch. Expected: $expectedHost, Found: ${hostnames.join(", ")}', ); return false; @@ -127,7 +127,7 @@ class NetworkUtils { } } } on Object catch (e) { - AppLogger.w('NetworkUtils: Failed to parse SAN from certificate DER', e); + AppLogger.e('NetworkUtils: Failed to parse SAN from certificate DER', e); } return hostnames.map((h) => h.toLowerCase()).toSet().toList(); diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 84fc0f1c..05470d9b 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -65,8 +65,8 @@ class _SecurityFailureApp extends StatelessWidget { title: 'Security Handshake Failed', message: friendlyMessage, technicalDetails: technicalDetails, - retryLabel: 'Close App', - onRetry: () => exit(0), + retryLabel: Platform.isAndroid ? 'Close App' : null, + onRetry: Platform.isAndroid ? () => exit(0) : null, ); }); return const Scaffold( @@ -113,8 +113,31 @@ Future _initializeFirebase() async { void main() async { SentryWidgetsFlutterBinding.ensureInitialized(); + // Initialize Sentry early to capture all startup exceptions + await SentryFlutter.init( + (options) { + options + ..dsn = AppConfig.sentryDsn + ..tracesSampleRate = kDebugMode ? 1.0 : 0.1 + ..release = 'ghostclass@${AppConfig.appVersion}' + ..environment = kDebugMode ? 'development' : 'production' + ..attachStacktrace = true + ..enableAutoPerformanceTracing = true; + }, + ); + HttpOverrides.global = MyHttpOverrides(); + FlutterError.onError = (details) { + FlutterError.presentError(details); + AppLogger.e('Flutter Framework Error', details.exception, details.stack); + }; + + PlatformDispatcher.instance.onError = (error, stack) { + AppLogger.e('Uncaught Async Error', error, stack); + return true; + }; + // Initialize Firebase & App Check try { await _initializeFirebase(); @@ -123,7 +146,7 @@ void main() async { try { await AnalyticsService.initialize(); } on Object catch (_) { - AppLogger.w('Analytics initialization failed'); + AppLogger.e('Analytics initialization failed'); } AppLogger.i('đŸ›Ąī¸ [FIREBASE SHIELD] Initializing App Check...'); @@ -147,32 +170,20 @@ void main() async { }, ); + await ThemeNotifier.preload(); + // Eagerly pre-warm cryptographic services concurrently while other SDKs/Fonts initialize - unawaited(JweService.instance.preWarm()); + AppLogger.safeUnawait(JweService.instance.preWarm(), 'JWE pre-warm'); await GoogleFonts.pendingFonts([ GoogleFonts.manrope(), GoogleFonts.firaCode(), ]); - // Initialize Sentry - await SentryFlutter.init( - (options) { - options - ..dsn = AppConfig.sentryDsn - ..tracesSampleRate = kDebugMode ? 1.0 : 0.1 - ..release = 'ghostclass@${AppConfig.appVersion}' - ..environment = kDebugMode ? 'development' : 'production' - ..attachStacktrace = true - ..enableAutoPerformanceTracing = true; - }, - appRunner: () { - return runApp( - ProviderScope( - child: SentryWidget(child: const MyApp()), - ), - ); - }, + runApp( + ProviderScope( + child: SentryWidget(child: const MyApp()), + ), ); await Sentry.addBreadcrumb( @@ -188,16 +199,6 @@ void main() async { }, ), ); - - FlutterError.onError = (details) { - FlutterError.presentError(details); - AppLogger.e('Flutter Framework Error', details.exception, details.stack); - }; - - PlatformDispatcher.instance.onError = (error, stack) { - AppLogger.e('Uncaught Async Error', error, stack); - return true; - }; } /// MyApp @@ -217,7 +218,10 @@ class _MyAppState extends ConsumerState { super.initState(); // Initialize push notification listeners and tokens after layout mounts WidgetsBinding.instance.addPostFrameCallback((_) { - final _ = ref.read(pushNotificationServiceProvider).initialize(); + AppLogger.safeUnawait( + ref.read(pushNotificationServiceProvider).initialize(), + 'Push init', + ); }); } diff --git a/mobile/lib/models/dashboard_stats.dart b/mobile/lib/models/dashboard_stats.dart index 04f4e29a..d4561823 100644 --- a/mobile/lib/models/dashboard_stats.dart +++ b/mobile/lib/models/dashboard_stats.dart @@ -237,7 +237,7 @@ class DashboardStats { final rawFinalAbsent = officialAbsent - savedAbsent + extraAbsent; final finalAbsentCount = rawFinalAbsent.clamp(0, double.maxFinite).toInt(); if (rawFinalAbsent < 0) { - AppLogger.w( + AppLogger.e( 'DashboardStats: Attendance invariant violation – finalAbsent was ' '$rawFinalAbsent, clamped to 0 ' '(official: $officialAbsent, saved: $savedAbsent, extra: $extraAbsent)', diff --git a/mobile/lib/providers/academic_provider.dart b/mobile/lib/providers/academic_provider.dart index 11212719..035c5a71 100644 --- a/mobile/lib/providers/academic_provider.dart +++ b/mobile/lib/providers/academic_provider.dart @@ -47,7 +47,7 @@ class AcademicState { return DateTime(parsed.$2); } // Unrecognised semester name — default to even-semester dates and warn. - AppLogger.w( + AppLogger.e( 'AcademicState.startDate: Unrecognised semester "$semester" for year "$year". ' 'Defaulting to even-semester start (Jan 1). Consider mapping this name.', ); @@ -64,7 +64,7 @@ class AcademicState { return DateTime(parsed.$2, 6, 30, 23, 59, 59); } // Unrecognised semester name — default to even-semester dates and warn. - AppLogger.w( + AppLogger.e( 'AcademicState.endDate: Unrecognised semester "$semester" for year "$year". ' 'Defaulting to even-semester end (Jun 30). Consider mapping this name.', ); @@ -82,42 +82,14 @@ final academicProvider = /// Manages the current academic context state, synchronizing with /// secure storage, user settings, and the EzyGo portal. class AcademicNotifier extends AsyncNotifier { - @override - FutureOr build() async { - // Read authProvider to see if we have a logged-in user. - // Do NOT watch it to avoid circular dependency loops with authProvider's initialization. - final auth = ref.read(authProvider).value; - if (auth == null) return null; - + Future _fetchFreshAcademicState() async { + final api = ref.read(apiServiceProvider); final storage = ref.read(secureStorageProvider); - // 1. Primary source: secure storage (written by auth startup flow) - final cached = await storage.getAcademicState(); - if (cached != null) return cached; - - // 2. Fallback: User settings from profile sync - final semester = auth.settings.semester; - final year = auth.settings.academicYear; - - if (semester != null && year != null) { - final state = AcademicState(semester: semester, year: year); - unawaited(storage.saveAcademicState(state)); - return state; - } - - // 3. Last resort: Calculate from current date - final fallback = calculateCurrentAcademicInfo(); - return AcademicState( - semester: fallback['current_semester']!, - year: fallback['current_year']!, - ); - } - - Future refreshFromEzygo() async { - state = const AsyncValue.loading(); - state = await AsyncValue.guard(() async { - final api = ref.read(apiServiceProvider); - final storage = ref.read(secureStorageProvider); + try { + // Always clear the local fetch cache before reading semester/year so app open + // reflects the current EzyGo defaults instead of a previous session. + api.clearCaches(); final results = await Future.wait>([ api.fetchSemester(storage), @@ -154,8 +126,76 @@ class AcademicNotifier extends AsyncNotifier { return next; } + AppLogger.e( + 'AcademicNotifier: Live EzyGo academic fetch returned incomplete data. Falling back to local state.', + ); + } on Object catch (e, st) { + AppLogger.e('AcademicNotifier: Live EzyGo academic fetch failed', e, st); + } + + return null; + } + + @override + FutureOr build() async { + // Read authProvider to see if we have a logged-in user. + // Do NOT watch it to avoid circular dependency loops with authProvider's initialization. + final auth = ref.read(authProvider).value; + if (auth == null) return null; + + final storage = ref.read(secureStorageProvider); + + // 1. Primary source: the academic context already seeded by auth/profile sync. + final seededSemester = + auth.profile?.currentSemester ?? auth.settings.semester; + final seededYear = auth.profile?.currentYear ?? auth.settings.academicYear; + + if (seededSemester != null && seededYear != null) { + final seeded = AcademicState(semester: seededSemester, year: seededYear); + AppLogger.safeUnawait( + storage.saveAcademicState(seeded).catchError((Object e, StackTrace st) { + AppLogger.e('AcademicNotifier: saveAcademicState failed', e, st); + }), + 'AcademicNotifier: saveAcademicState', + ); + return seeded; + } + + // 2. Fallback: secure storage (written by auth startup flow) + final cached = await storage.getAcademicState(); + if (cached != null) return cached; + + // 3. Last resort: Calculate from current date + final fallback = calculateCurrentAcademicInfo(); + return AcademicState( + semester: fallback['current_semester']!, + year: fallback['current_year']!, + ); + } + + Future refreshFromEzygo() async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(() async { + final storage = ref.read(secureStorageProvider); + + final fresh = await _fetchFreshAcademicState(); + if (fresh != null) return fresh; + final cached = await storage.getAcademicState(); - return cached ?? state.value; + if (cached != null) return cached; + + final auth = ref.read(authProvider).value; + final semester = auth?.settings.semester; + final year = auth?.settings.academicYear; + if (semester != null && year != null) { + return AcademicState(semester: semester, year: year); + } + + final fallback = calculateCurrentAcademicInfo(); + return AcademicState( + semester: fallback['current_semester']!, + year: fallback['current_year']!, + ); }); } @@ -195,10 +235,22 @@ class AcademicNotifier extends AsyncNotifier { ref.invalidateSelf(); } } + + Future setAcademicPeriod(String semester, String year) async { + state = const AsyncValue.loading(); + + try { + await ref + .read(authProvider.notifier) + .updateAcademicContext(semester, year); + } finally { + ref.invalidateSelf(); + } + } } (int, int) _parseAcademicYear(String year) { - final parts = year.split('-'); + final parts = year.replaceAll('/', '-').split('-'); // Expand short-form start year: "25-26" → "2025-2026". var startPart = parts.isNotEmpty ? parts.first : ''; diff --git a/mobile/lib/providers/auth_provider.dart b/mobile/lib/providers/auth_provider.dart index 79b470eb..652d3701 100644 --- a/mobile/lib/providers/auth_provider.dart +++ b/mobile/lib/providers/auth_provider.dart @@ -1,4 +1,5 @@ import 'dart:async'; + import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -11,19 +12,14 @@ import 'package:ghostclass/logic/error_utils.dart'; import 'package:ghostclass/models/institution.dart'; import 'package:ghostclass/models/user.dart'; import 'package:ghostclass/providers/academic_provider.dart'; -import 'package:ghostclass/providers/dashboard_provider.dart'; -import 'package:ghostclass/providers/leave_provider.dart'; -import 'package:ghostclass/providers/score_provider.dart'; import 'package:ghostclass/providers/security_provider.dart'; -import 'package:ghostclass/providers/tracking_provider.dart'; import 'package:ghostclass/services/analytics_service.dart'; import 'package:ghostclass/services/api_service.dart'; +import 'package:ghostclass/services/cache_manager.dart'; import 'package:ghostclass/services/logger.dart'; import 'package:ghostclass/services/profile_service.dart'; import 'package:ghostclass/services/secure_storage.dart'; import 'package:ghostclass/services/settings_service.dart'; -import 'package:ghostclass/services/stealth_headers_service.dart'; -import 'package:shared_preferences/shared_preferences.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; class LoginException implements Exception { @@ -35,9 +31,6 @@ class LoginException implements Exception { // ─── Providers ──────────────────────────────────────────────────────────────── -final Provider stealthHeadersServiceProvider = Provider( - (ref) => StealthHeadersService(ref.watch(secureStorageProvider)), -); final Provider profileServiceProvider = Provider( (ref) => ProfileService(), ); @@ -159,6 +152,9 @@ class AuthNotifier extends AsyncNotifier bool _isRefreshing = false; bool _isInitializing = false; int _consecutiveHealFailures = 0; + int _profileRefreshGeneration = 0; + Future? _refreshProfileInFlight; + Future? _profileRefreshInFlight; @override FutureOr build() async { @@ -166,18 +162,34 @@ class AuthNotifier extends AsyncNotifier final apiService = ref.read(apiServiceProvider); final unauthorizedSub = apiService.onUnauthorized.listen((_) { - final _ = _handleUnauthorized(); + AppLogger.safeUnawait( + _handleUnauthorized(), + 'AuthNotifier: handleUnauthorized', + ); }); - final lockdownSub = apiService.onSecurityLockdown.listen( - _handleSecurityLockdown, - ); + final lockdownSub = apiService.onSecurityLockdown.listen((data) { + AppLogger.safeUnawait( + _handleSecurityLockdown(data), + 'AuthNotifier: securityLockdown', + ); + }); ref.onDispose(() { WidgetsBinding.instance.removeObserver(this); _refreshTimer?.cancel(); - unawaited(unauthorizedSub.cancel()); - unawaited(lockdownSub.cancel()); + AppLogger.safeUnawait( + unauthorizedSub.cancel().catchError((Object e, StackTrace st) { + AppLogger.e('AuthNotifier: unauthorizedSub.cancel failed', e, st); + }), + 'AuthNotifier: unauthorizedSub.cancel', + ); + AppLogger.safeUnawait( + lockdownSub.cancel().catchError((Object e, StackTrace st) { + AppLogger.e('AuthNotifier: lockdownSub.cancel failed', e, st); + }), + 'AuthNotifier: lockdownSub.cancel', + ); }); _startPeriodicRefresh(); @@ -202,44 +214,119 @@ class AuthNotifier extends AsyncNotifier final backgroundDuration = now.difference(_lastBackgroundedAt!); if (backgroundDuration < const Duration(minutes: 15)) return; } - final _ = refreshProfile(force: true); + AppLogger.safeUnawait( + refreshProfile(force: true), + 'AuthNotifier: refreshProfile on resume', + ); } } void _startPeriodicRefresh() { _refreshTimer?.cancel(); _refreshTimer = Timer.periodic(const Duration(minutes: 30), (_) { - final _ = refreshProfile(); + AppLogger.safeUnawait(refreshProfile(), 'AuthNotifier: periodic refresh'); }); } Future _handleUnauthorized() async { if (_isRefreshing || _isInitializing) return; _isRefreshing = true; + final healAttemptId = DateTime.now().microsecondsSinceEpoch.toString(); final api = ref.read(apiServiceProvider)..suppress401 = true; - AppLogger.w('AuthNotifier: 401 DETECTED. Attempting self-healing...'); + AppLogger.e('AuthNotifier: 401 DETECTED. Attempting self-healing...'); + AppLogger.d( + 'AuthNotifier [HEAL-$healAttemptId]: Starting heal with $_consecutiveHealFailures prior failures', + ); try { + // Adaptive backoff based on consecutive failures: 0ms, 500ms, 1s, 2s, 4s (capped at 5s) + final backoffMs = _consecutiveHealFailures > 0 + ? (500 * (1 << (_consecutiveHealFailures - 1))).clamp(500, 5000) + : 0; + if (backoffMs > 0) { + AppLogger.d( + 'AuthNotifier [HEAL-$healAttemptId]: Waiting ${backoffMs}ms before retry (attempt ${_consecutiveHealFailures + 1})', + ); + await Future.delayed(Duration(milliseconds: backoffMs)); + } + final oldToken = state.value?.ezygoToken; if (state.value == null) { final recoveredUser = await _buildFromCurrentSession(); - if (recoveredUser != null) state = AsyncValue.data(recoveredUser); + if (recoveredUser != null) { + state = AsyncValue.data(recoveredUser); + AppLogger.d( + 'AuthNotifier [HEAL-$healAttemptId]: Recovered user from session', + ); + } } final supabaseToken = await _getFreshSupabaseToken(); if (supabaseToken == null) { + AppLogger.e( + 'AuthNotifier [HEAL-$healAttemptId]: Supabase token unavailable, logging out', + ); await logout(); return; } - final syncRes = await api.syncMobileAuth(supabaseToken); - if (syncRes.statusCode == 200 && syncRes.data is Map) { + Response? syncRes; + try { + AppLogger.d( + 'AuthNotifier [HEAL-$healAttemptId]: Calling syncMobileAuth (attempt 1/2)', + ); + syncRes = await api + .syncMobileAuth(supabaseToken) + .timeout(const Duration(seconds: 10)); + } on TimeoutException catch (e, st) { + AppLogger.e( + 'AuthNotifier [HEAL-$healAttemptId]: syncMobileAuth timed out (attempt 1)', + e, + st, + ); + syncRes = null; + } + + // If the first attempt failed, do a single retry with a short backoff + if (syncRes == null || syncRes.statusCode != 200) { + try { + await Future.delayed(const Duration(milliseconds: 500)); + AppLogger.d( + 'AuthNotifier [HEAL-$healAttemptId]: Calling syncMobileAuth (attempt 2/2)', + ); + syncRes = await api + .syncMobileAuth(supabaseToken) + .timeout(const Duration(seconds: 10)); + } on Object catch (e, st) { + AppLogger.e( + 'AuthNotifier [HEAL-$healAttemptId]: syncMobileAuth retry failed', + e, + st, + ); + syncRes = null; + } + } + + if (syncRes != null && + syncRes.statusCode == 200 && + syncRes.data is Map) { final syncData = syncRes.data as Map; final syncedToken = (syncData['ezygo_token'] as String?)?.trim(); if (syncedToken != null && syncedToken.isNotEmpty) { - await ref.read(secureStorageProvider).saveEzygoToken(syncedToken); + try { + await ref.read(secureStorageProvider).saveEzygoToken(syncedToken); + AppLogger.d( + 'AuthNotifier [HEAL-$healAttemptId]: Persisted synced ezygo token', + ); + } on Object catch (e, st) { + AppLogger.e( + 'AuthNotifier [HEAL-$healAttemptId]: Failed to persist synced ezygo token', + e, + st, + ); + } final current = state.value; if (current != null) { @@ -252,20 +339,26 @@ class AuthNotifier extends AsyncNotifier ezygoId: syncedEzygoId, ), ); + AppLogger.d( + 'AuthNotifier [HEAL-$healAttemptId]: Updated state with synced token', + ); } } } + AppLogger.d('AuthNotifier [HEAL-$healAttemptId]: Refreshing profile'); await refreshProfile(force: true); final newToken = state.value?.ezygoToken; if (newToken != null && newToken != oldToken) { - AppLogger.i('AuthNotifier: SELF-HEALING SUCCESSFUL.'); + AppLogger.i( + 'AuthNotifier [HEAL-$healAttemptId]: SELF-HEALING SUCCESSFUL. Token changed', + ); _consecutiveHealFailures = 0; } else { _consecutiveHealFailures++; - AppLogger.w( - 'AuthNotifier: Self-healing did not produce a new token. Failures: $_consecutiveHealFailures', + AppLogger.e( + 'AuthNotifier [HEAL-$healAttemptId]: Self-healing did not produce a new token. Consecutive failures: $_consecutiveHealFailures', ); if (_consecutiveHealFailures >= 3) { @@ -275,38 +368,47 @@ class AuthNotifier extends AsyncNotifier lastError.details?['type'] == 'security'; if (isSecurityError) { - AppLogger.w( - 'AuthNotifier: Terminal security block. Not logging out.', + AppLogger.e( + 'AuthNotifier [HEAL-$healAttemptId]: Terminal security block detected. Not logging out.', ); _consecutiveHealFailures = 0; // Reset to allow more attempts later } else { AppLogger.e( - 'AuthNotifier: Terminal 401 loop detected. Logging out to protect state.', + 'AuthNotifier [HEAL-$healAttemptId]: Terminal 401 loop detected after $_consecutiveHealFailures attempts. Logging out to protect state.', ); await logout(); } } } } on Object catch (e) { - AppLogger.e('AuthNotifier: Self-healing error', e); + AppLogger.e('AuthNotifier [HEAL-$healAttemptId]: Self-healing error', e); if (e is AppException && e.isAuthError) { final isSecurityError = e.details?['type'] == 'security'; final isCritical = e.details?['criticalRisk'] == true; if (isSecurityError && !isCritical) { - AppLogger.w( - 'AuthNotifier: Non-critical security block. Skipping logout.', + AppLogger.e( + 'AuthNotifier [HEAL-$healAttemptId]: Non-critical security block. Skipping logout.', ); } else { if (isCritical) { - AppLogger.e('AuthNotifier: CRITICAL SECURITY RISK. Logging out.'); + AppLogger.e( + 'AuthNotifier [HEAL-$healAttemptId]: CRITICAL SECURITY RISK. Logging out.', + ); } await logout(); } } } finally { - // Cooldown before allowing next 401 triggers to prevent tight cascades - await Future.delayed(const Duration(milliseconds: 1000)); + // Cooldown before allowing next 401 triggers to prevent tight cascades. + // Cooldown increases with consecutive failures (1s baseline, up to 5s). + final cooldownMs = _consecutiveHealFailures > 0 + ? (500 * (1 << (_consecutiveHealFailures - 1))).clamp(500, 5000) + : 1000; + AppLogger.d( + 'AuthNotifier [HEAL-$healAttemptId]: Cooldown for ${cooldownMs}ms before next 401 can trigger', + ); + await Future.delayed(Duration(milliseconds: cooldownMs)); api.suppress401 = false; _isRefreshing = false; } @@ -330,7 +432,26 @@ class AuthNotifier extends AsyncNotifier await logout(force: true); } - Future refreshProfile({bool force = false}) async { + Future refreshProfile({ + bool force = false, + }) async { + final inFlight = _refreshProfileInFlight; + if (inFlight != null) return inFlight; + + final future = _refreshProfileInternal( + force: force, + ); + _refreshProfileInFlight = future; + return future.whenComplete(() { + if (identical(_refreshProfileInFlight, future)) { + _refreshProfileInFlight = null; + } + }); + } + + Future _refreshProfileInternal({ + bool force = false, + }) async { final currentUser = state.value; if (currentUser == null) return; @@ -365,7 +486,7 @@ class AuthNotifier extends AsyncNotifier final isCritical = e.details?['criticalRisk'] == true; if (isSecurityError && !isCritical) { - AppLogger.w( + AppLogger.e( 'AuthNotifier: Non-critical security block. Skipping logout.', ); } else { @@ -409,7 +530,7 @@ class AuthNotifier extends AsyncNotifier if (session == null) return null; final storage = ref.read(secureStorageProvider); - final ezygoToken = await storage.getEzygoToken(); + final ezygoToken = await storage.getNormalizedEzygoToken(); final user = await _buildStoredUserForIdentity( supabaseUserId: session.user.id, @@ -438,7 +559,7 @@ class AuthNotifier extends AsyncNotifier } // 1. Fetch Profile and trigger backend full EzyGo sync synchronously - await _fetchAndApplyServerProfile( + await _runProfileRefresh( cachedUser, supabaseToken: token, sync: true, @@ -446,10 +567,16 @@ class AuthNotifier extends AsyncNotifier _lastRefresh = DateTime.now(); // Pre-fetch institutions so they are ready in settings - unawaited(ref.read(institutionsProvider.future)); - - // 2. Trigger EzyGo cron sync in background (non-blocking) - unawaited(api.triggerSync(token, force: true)); + AppLogger.safeUnawait( + ref.read(institutionsProvider.future).catchError(( + Object e, + StackTrace st, + ) { + AppLogger.e('AuthNotifier: prefetch institutions failed', e, st); + return []; + }), + 'AuthNotifier: prefetch institutions', + ); // If we are not running silently, clear the syncing status to unlock the UI if (!silent) { @@ -466,7 +593,7 @@ class AuthNotifier extends AsyncNotifier return; } - AppLogger.w( + AppLogger.e( 'AuthNotifier: Background startup hydration failed. Using cached data.', e, ); @@ -562,14 +689,68 @@ class AuthNotifier extends AsyncNotifier academicYear: initialYear?.toString(), ); - await Future.wait([ - storage.saveEzygoToken(ezygoToken), - storage.saveSupabaseUserId(supabaseUser.id), - storage.saveUsername(username), - storage.saveSettings(settingsWithAcademic), - if (ezygoId != null) storage.saveEzygoUserId(ezygoId), - if (termsVersion != null) storage.saveTermsVersion(termsVersion), - ]); + final saves = >[ + storage.saveEzygoToken(ezygoToken).catchError(( + Object e, + StackTrace st, + ) { + AppLogger.e( + 'AuthNotifier: Failed to persist ezygo token (post-login)', + e, + st, + ); + }), + storage.saveSupabaseUserId(supabaseUser.id).catchError(( + Object e, + StackTrace st, + ) { + AppLogger.e( + 'AuthNotifier: Failed to persist supabase user id (post-login)', + e, + st, + ); + }), + storage.saveUsername(username).catchError((Object e, StackTrace st) { + AppLogger.e( + 'AuthNotifier: Failed to persist username (post-login)', + e, + st, + ); + }), + storage.saveSettings(settingsWithAcademic).catchError(( + Object e, + StackTrace st, + ) { + AppLogger.e( + 'AuthNotifier: Failed to persist settings (post-login)', + e, + st, + ); + }), + if (ezygoId != null) + storage.saveEzygoUserId(ezygoId).catchError(( + Object e, + StackTrace st, + ) { + AppLogger.e( + 'AuthNotifier: Failed to persist ezygo user id (post-login)', + e, + st, + ); + }), + if (termsVersion != null) + storage.saveTermsVersion(termsVersion).catchError(( + Object e, + StackTrace st, + ) { + AppLogger.e( + 'AuthNotifier: Failed to persist terms version (post-login)', + e, + st, + ); + }), + ]; + await Future.wait(saves); final cachedUser = await _buildStoredUserForIdentity( supabaseUserId: supabaseUser.id, @@ -583,7 +764,15 @@ class AuthNotifier extends AsyncNotifier final profileService = ref.read(profileServiceProvider); if (profileService.hasRenderableLocalProfile(cachedUser.profile)) { state = AsyncValue.data(cachedUser); - unawaited(Future.microtask(() => refreshProfile(force: true))); + AppLogger.safeUnawait( + Future.microtask(() => refreshProfile(force: true)).catchError(( + Object e, + StackTrace st, + ) { + AppLogger.e('AuthNotifier: Async refresh failed', e, st); + }), + 'AuthNotifier: async refreshProfile', + ); try { await AnalyticsService.instance.logLogin(method: 'ezygo'); } on Object catch (_) {} @@ -593,7 +782,7 @@ class AuthNotifier extends AsyncNotifier final token = await _getFreshSupabaseToken(); if (token != null) { // Mark as syncing and trigger backend full EzyGo sync - final profiledUser = await _fetchAndApplyServerProfile( + final profiledUser = await _runProfileRefresh( cachedUser, supabaseToken: token, updateState: false, @@ -601,31 +790,47 @@ class AuthNotifier extends AsyncNotifier ); final syncingUser = profiledUser.copyWith(isSyncing: true); state = AsyncValue.data(syncingUser); - unawaited( + AppLogger.safeUnawait( Future.microtask(() async { try { // Pre-fetch institutions so they are ready in settings - unawaited(ref.read(institutionsProvider.future)); - - unawaited( - ref.read(apiServiceProvider).triggerSync(token, force: true), + AppLogger.safeUnawait( + ref.read(institutionsProvider.future).catchError(( + Object e, + StackTrace st, + ) { + AppLogger.e( + 'AuthNotifier: prefetch institutions failed (post-login)', + e, + st, + ); + return []; + }), + 'AuthNotifier: prefetch institutions (post-login)', ); } on Object catch (e) { - AppLogger.w('AuthNotifier: Post-login cron sync failed', e); + AppLogger.e('AuthNotifier: Post-login cron sync failed', e); } finally { final finalUser = state.value; if (finalUser != null) { state = AsyncValue.data(finalUser.copyWith(isSyncing: false)); } } + }).catchError((Object e, StackTrace st) { + AppLogger.e('AuthNotifier: Post-login microtask failed', e, st); }), + 'AuthNotifier: post-login microtask', ); + // Ensure any errors in the background microtask are logged + // (the microtask itself contains its own try/catch, but attach + // a top-level catcher to be defensive). + // Note: we purposely keep this fire-and-forget behavior. try { await AnalyticsService.instance.logLogin(method: 'ezygo'); } on Object catch (_) {} } else { - await _fetchAndApplyServerProfile(cachedUser); + await _runProfileRefresh(cachedUser); try { await AnalyticsService.instance.logLogin(method: 'ezygo'); } on Object catch (_) {} @@ -634,7 +839,7 @@ class AuthNotifier extends AsyncNotifier AppLogger.e('AuthNotifier: SUPABASE AUTH ERROR', e); state = AsyncValue.error(e, st); rethrow; - } catch (e, st) { + } on Object catch (e, st) { AppLogger.e('AuthNotifier: LOGIN ERROR', e); state = AsyncValue.error(e, st); rethrow; @@ -642,23 +847,38 @@ class AuthNotifier extends AsyncNotifier } Future logout({bool force = false}) async { - final storage = ref.read(secureStorageProvider); // Reset security failure state on normal logout so the next login starts clean. if (!force) { ref.read(securityFailureProvider.notifier).clearFailure(); } - ref.read(apiServiceProvider).clearCaches(); + await ref.read(cacheManagerProvider).clearAllCaches(); ref ..invalidate(institutionsProvider) ..invalidate(academicProvider); state = const AsyncValue.data(null); + // Stop periodic refreshes and prevent in-flight refresh continuations + _refreshTimer?.cancel(); + _profileRefreshGeneration++; + _refreshProfileInFlight = null; + _profileRefreshInFlight = null; + // Wipe any in-memory entropy used by `EncryptedValue` to prevent + // reconstruction of plaintext tokens after logout. + EncryptedValue.clearEntropy(); try { - await Future.wait([ + // On forced logout (e.g. security lockdown), also wipe sensitive + // secure storage entries such as EzyGo and FCM tokens. + final storage = ref.read(secureStorageProvider); + final ops = >[ ref.read(supabaseClientProvider).auth.signOut(), - storage.clearAll(), - _clearSharedPrefs(), - ]); + ]; + if (force) { + ops.addAll([ + storage.clearEzygoToken(), + storage.saveFcmToken(''), + ]); + } + await Future.wait(ops); } on Object catch (e) { AppLogger.e('AuthNotifier: LOGOUT CLEANUP ERROR', e); } @@ -667,15 +887,6 @@ class AuthNotifier extends AsyncNotifier } on Object catch (_) {} } - Future _clearSharedPrefs() async { - try { - final prefs = await SharedPreferences.getInstance(); - await prefs.clear(); - } on Object catch (e, st) { - AppLogger.e('AuthNotifier: Failed to clear shared preferences', e, st); - } - } - Future updateAvatar(String publicUrl) async { final user = state.value; if (user == null) return; @@ -752,7 +963,7 @@ class AuthNotifier extends AsyncNotifier } } on Object catch (_) {} } on Object catch (e) { - AppLogger.w( + AppLogger.e( 'AuthNotifier: Settings persistence failed, rolling back.', e, ); @@ -784,25 +995,28 @@ class AuthNotifier extends AsyncNotifier try { // 1. Inform Ezygo of the change first (matches web parity) - final updates = >[]; - if (sem != null) updates.add(api.updateSemester(sem, storage)); - if (year != null) updates.add(api.updateAcademicYear(year, storage)); - - if (updates.isNotEmpty) { - final results = await Future.wait(updates); - for (final r in results) { - final res = r as Response; - if (res.statusCode != 200 && res.statusCode != 201) { - final resData = res.data as Map?; - throw Exception(formatApiError(resData, 'Auth.AcademicUpdate')); - } + if (year != null) { + final yearResponse = await api.updateAcademicYear(year, storage); + if (yearResponse.statusCode != 200 && yearResponse.statusCode != 201) { + final resData = yearResponse.data as Map?; + throw Exception(formatApiError(resData, 'Auth.AcademicUpdate')); + } + } + + if (sem != null) { + final semesterResponse = await api.updateSemester(sem, storage); + if (semesterResponse.statusCode != 200 && + semesterResponse.statusCode != 201) { + final resData = semesterResponse.data as Map?; + throw Exception(formatApiError(resData, 'Auth.AcademicUpdate')); } } // 2. Update the dedicated academic state in storage immediately so providers see it + AcademicState? nextAcademic; if (sem != null || year != null) { final currentAcademic = await storage.getAcademicState(); - final nextAcademic = AcademicState( + nextAcademic = AcademicState( semester: sem ?? currentAcademic?.semester ?? @@ -859,9 +1073,6 @@ class AuthNotifier extends AsyncNotifier ); } - // 2. Trigger the backend cron sync in the background (non-blocking) - unawaited(api.triggerSync(token, force: true)); - await _applyProfileResponseData( currentUser: syncingUser, data: response.data as Map, @@ -876,14 +1087,14 @@ class AuthNotifier extends AsyncNotifier AppLogger.i( 'AuthNotifier: Academic context updated successfully ($sem, $year)', ); - } catch (e) { + } on Object catch (e) { AppLogger.e('AuthNotifier: Failed to update academic context', e); if (e is AppException && e.isAuthError) { final isSecurityError = e.details?['type'] == 'security'; final isCritical = e.details?['criticalRisk'] == true; if (isSecurityError && !isCritical) { - AppLogger.w( + AppLogger.e( 'AuthNotifier: Non-critical security block. Skipping logout.', ); } else { @@ -912,7 +1123,7 @@ class AuthNotifier extends AsyncNotifier // Update local state by re-fetching profile (this ensures ID and other fields sync) await refreshProfile(force: true); - } catch (e) { + } on Object catch (e) { AppLogger.e('AuthNotifier: Institution update failed', e); rethrow; } @@ -980,6 +1191,7 @@ class AuthNotifier extends AsyncNotifier bool sync = false, bool force = false, }) async { + final refreshGeneration = _profileRefreshGeneration; final token = supabaseToken ?? await _getFreshSupabaseToken(); if (token == null) { throw const AppException( @@ -989,7 +1201,11 @@ class AuthNotifier extends AsyncNotifier } final api = ref.read(apiServiceProvider); - final response = await api.refreshProfile(token, sync: sync, force: force); + final response = await api.refreshProfile( + token, + sync: sync, + force: force, + ); if (response.statusCode == 401) { final data = response.data as Map?; @@ -1020,13 +1236,43 @@ class AuthNotifier extends AsyncNotifier updateState: false, ); - if (updateState) { - state = AsyncValue.data(updatedUser); + if (updateState && refreshGeneration == _profileRefreshGeneration) { + final currentState = state.value; + if (currentState == null || + currentState.supabaseUserId == user.supabaseUserId) { + state = AsyncValue.data(updatedUser); + } } return updatedUser; } + Future _runProfileRefresh( + AuthenticatedUser user, { + String? supabaseToken, + bool updateState = true, + bool sync = false, + bool force = false, + }) { + final inFlight = _profileRefreshInFlight; + if (inFlight != null) return inFlight; + + final future = _fetchAndApplyServerProfile( + user, + supabaseToken: supabaseToken, + updateState: updateState, + sync: sync, + force: force, + ); + _profileRefreshInFlight = future; + + return future.whenComplete(() { + if (identical(_profileRefreshInFlight, future)) { + _profileRefreshInFlight = null; + } + }); + } + Future _applyProfileResponseData({ required AuthenticatedUser currentUser, required Map data, @@ -1077,19 +1323,98 @@ class AuthNotifier extends AsyncNotifier ) : null; - await Future.wait([ - storage.saveEzygoToken(mergedUser.ezygoToken.value), - storage.saveSupabaseUserId(mergedUser.supabaseUserId), - storage.saveSettings(settings), - storage.saveUserProfile(profile), + // If the user has logged out while this refresh was in-flight, skip + // persisting any profile or token changes to avoid reintroducing + // sensitive data after a forced logout. + if (state.value == null) { + AppLogger.i( + 'AuthNotifier: Skipping profile apply because user logged out during refresh', + ); + _lastRefresh = DateTime.now(); + return mergedUser; + } + + final saves = >[ + storage.saveEzygoToken(mergedUser.ezygoToken.value).catchError(( + Object e, + StackTrace st, + ) { + AppLogger.e( + 'AuthNotifier: Failed to persist ezygo token (profile apply)', + e, + st, + ); + }), + storage.saveSupabaseUserId(mergedUser.supabaseUserId).catchError(( + Object e, + StackTrace st, + ) { + AppLogger.e( + 'AuthNotifier: Failed to persist supabase id (profile apply)', + e, + st, + ); + }), + storage.saveSettings(settings).catchError((Object e, StackTrace st) { + AppLogger.e( + 'AuthNotifier: Failed to persist settings (profile apply)', + e, + st, + ); + }), + storage.saveUserProfile(profile).catchError((Object e, StackTrace st) { + AppLogger.e( + 'AuthNotifier: Failed to persist profile (profile apply)', + e, + st, + ); + }), if (mergedUser.ezygoId != null) - storage.saveEzygoUserId(mergedUser.ezygoId!), + storage.saveEzygoUserId(mergedUser.ezygoId!).catchError(( + Object e, + StackTrace st, + ) { + AppLogger.e( + 'AuthNotifier: Failed to persist ezygo id (profile apply)', + e, + st, + ); + }), if (mergedUser.username != null) - storage.saveUsername(mergedUser.username!), + storage.saveUsername(mergedUser.username!).catchError(( + Object e, + StackTrace st, + ) { + AppLogger.e( + 'AuthNotifier: Failed to persist username (profile apply)', + e, + st, + ); + }), if (mergedUser.termsVersion != null) - storage.saveTermsVersion(mergedUser.termsVersion!), - if (nextAcademic != null) storage.saveAcademicState(nextAcademic), - ]); + storage.saveTermsVersion(mergedUser.termsVersion!).catchError(( + Object e, + StackTrace st, + ) { + AppLogger.e( + 'AuthNotifier: Failed to persist terms version (profile apply)', + e, + st, + ); + }), + if (nextAcademic != null) + storage.saveAcademicState(nextAcademic).catchError(( + Object e, + StackTrace st, + ) { + AppLogger.e( + 'AuthNotifier: Failed to persist academic state (profile apply)', + e, + st, + ); + }), + ]; + await Future.wait(saves); final academicChanged = nextAcademic != null && @@ -1098,37 +1423,18 @@ class AuthNotifier extends AsyncNotifier if (academicChanged) { ref.read(apiServiceProvider).clearCaches(); - try { - final api = ref.read(apiServiceProvider); - await Future.wait([ - api.updateSemester(nextAcademic.semester, storage), - api.updateAcademicYear(nextAcademic.year, storage), - ]); - AppLogger.i( - 'AuthNotifier: Successfully synced EzyGo session state to new academic context: ${nextAcademic.semester} ${nextAcademic.year}', - ); - } on Object catch (e) { - AppLogger.w( - 'AuthNotifier: Failed to update EzyGo active semester during self-heal', - e, - ); - } - - // Clear all page caches/providers to force clean re-fetch of all pages - ref - ..invalidate(dashboardProvider) - ..invalidate(trackingProvider) - ..invalidate(scoreProvider) - ..invalidate(leaveProvider); } if (nextAcademic != null) { - unawaited( + AppLogger.safeUnawait( Future.delayed(Duration.zero, () { ref.read(academicProvider.notifier).state = AsyncValue.data( nextAcademic, ); + }).catchError((Object e, StackTrace st) { + AppLogger.e('AuthNotifier: Deferred academic set failed', e, st); }), + 'AuthNotifier: deferred academic set', ); } @@ -1145,7 +1451,8 @@ class AuthNotifier extends AsyncNotifier final res = await ref .read(supabaseClientProvider) .auth - .refreshSession(); + .refreshSession() + .timeout(const Duration(seconds: 10)); return res.session?.accessToken; } return session.accessToken; @@ -1160,13 +1467,13 @@ class AuthNotifier extends AsyncNotifier e.message.contains('not found'); if (isTerminal) { - AppLogger.w('AuthNotifier: Supabase session terminal failure', e); + AppLogger.e('AuthNotifier: Supabase session terminal failure', e); return null; } // For other AuthExceptions (e.g. 500s, rate limits), treat as transient // network errors to avoid logging out the user prematurely. - AppLogger.w( + AppLogger.e( 'AuthNotifier: Supabase transient auth error. Preventing logout.', e, ); @@ -1176,7 +1483,7 @@ class AuthNotifier extends AsyncNotifier originalError: e, ); } on Object catch (e) { - AppLogger.w( + AppLogger.e( 'AuthNotifier: Network error during token refresh. Preventing logout.', e, ); diff --git a/mobile/lib/providers/dashboard_provider.dart b/mobile/lib/providers/dashboard_provider.dart index 68c762ea..51fd7041 100644 --- a/mobile/lib/providers/dashboard_provider.dart +++ b/mobile/lib/providers/dashboard_provider.dart @@ -14,6 +14,7 @@ import 'package:ghostclass/providers/notification_provider.dart'; import 'package:ghostclass/providers/tracking_provider.dart'; import 'package:ghostclass/services/api_service.dart'; import 'package:ghostclass/services/logger.dart'; +import 'package:ghostclass/services/refresh_coordinator.dart'; import 'package:ghostclass/services/secure_storage.dart'; class DashboardData { @@ -140,7 +141,7 @@ class DashboardNotifier extends AsyncNotifier { } } } on Object catch (e) { - AppLogger.w('DashboardNotifier: Error loading disk cache', e); + AppLogger.e('DashboardNotifier: Error loading disk cache', e); } } @@ -177,8 +178,19 @@ class DashboardNotifier extends AsyncNotifier { _lastAcademic == academic) { if (_needsRevalidate) { _needsRevalidate = false; - unawaited( - Future.microtask(() => _silentRevalidate(trackingList, academic)), + AppLogger.safeUnawait( + Future.microtask( + () => _silentRevalidate(trackingList, academic), + ).catchError( + (Object e, StackTrace st) { + AppLogger.e( + 'DashboardNotifier: Silent revalidate failed', + e, + st, + ); + }, + ), + 'DashboardNotifier: silent revalidate', ); } @@ -300,23 +312,26 @@ class DashboardNotifier extends AsyncNotifier { if (user != null) { final cacheKeySuffix = '${user.supabaseUserId}_${academic.semester}_${academic.year}'; - unawaited( + AppLogger.safeUnawait( storage.saveCachedData( 'dashboard_courses_$cacheKeySuffix', _cachedCourses!.map((c) => c.toJson()).toList(), ), + 'Dashboard: save courses cache', ); - unawaited( + AppLogger.safeUnawait( storage.saveCachedData( 'dashboard_attendance_$cacheKeySuffix', _cachedAttendance!.toJson(), ), + 'Dashboard: save attendance cache', ); - unawaited( + AppLogger.safeUnawait( storage.saveCachedData( 'dashboard_instructors_$cacheKeySuffix', _cachedInstructors!.map((i) => i.toJson()).toList(), ), + 'Dashboard: save instructors cache', ); } @@ -327,7 +342,7 @@ class DashboardNotifier extends AsyncNotifier { academic, sharedInstructors, ); - } catch (e) { + } on Object catch (e) { AppLogger.e('DashboardNotifier: Server fetch failed', e); rethrow; } @@ -513,9 +528,17 @@ class DashboardNotifier extends AsyncNotifier { ) async { try { final freshData = await _fetchAndProcess(tracking, academic, null); - state = AsyncValue.data(freshData); + final currentAcademic = ref.read(academicProvider).value; + if (academic == currentAcademic) { + state = AsyncValue.data(freshData); + } else { + AppLogger.i( + 'DashboardNotifier: Discarded stale silent revalidation for ' + '${academic.semester} ${academic.year} (current: ${currentAcademic?.semester} ${currentAcademic?.year})', + ); + } } on Object catch (e) { - AppLogger.w( + AppLogger.e( 'DashboardNotifier: Silent background revalidation failed', e, ); @@ -525,43 +548,54 @@ class DashboardNotifier extends AsyncNotifier { Future refresh() async { ref.invalidate(notificationsProvider); final user = ref.read(authProvider).value; - final api = ref.read(apiServiceProvider); - final supabaseToken = ref - .read(supabaseClientProvider) - .auth - .currentSession - ?.accessToken; // 0. Set local loading state state = const AsyncValue.loading(); - if (user != null && supabaseToken != null) { - // 1. Trigger the server-side sync (this updates the database) - // We await this to ensure the server has latest data before we fetch it back - await api.triggerSync(supabaseToken, force: true); - - // 2. Fetch the fresh profile from the server - await ref.read(authProvider.notifier).refreshProfile(force: true); + Object? refreshError; + StackTrace? refreshStackTrace; - // Clear disk cache for current user/term so refresh is guaranteed fresh - final storage = ref.read(secureStorageProvider); - final academicAsync = ref.read(academicProvider); - final academic = academicAsync.value; - if (academic != null) { - final suffix = - '${user.supabaseUserId}_${academic.semester}_${academic.year}'; - await Future.wait([ - storage.deleteCachedData('dashboard_courses_$suffix'), - storage.deleteCachedData('dashboard_attendance_$suffix'), - storage.deleteCachedData('dashboard_instructors_$suffix'), - ]); + if (user != null) { + try { + await runUnifiedPullToRefresh( + logLabel: 'DashboardNotifier', + refreshProfile: () => + ref.read(authProvider.notifier).refreshProfile(force: true), + syncCron: () async { + final supabaseToken = ref + .read(supabaseClientProvider) + .auth + .currentSession + ?.accessToken; + if (supabaseToken == null) return; + await ref + .read(apiServiceProvider) + .triggerSync(supabaseToken, force: true); + }, + refreshData: () => ref.read(trackingProvider.notifier).refresh(), + ); + } on Object catch (e, st) { + AppLogger.e('DashboardNotifier: refresh coordinator failed', e, st); + refreshError = e; + refreshStackTrace = st; + } finally { + // Clear disk cache even when refresh fails so the next launch or retry + // cannot silently reuse stale dashboard data. + final storage = ref.read(secureStorageProvider); + final academicAsync = ref.read(academicProvider); + final academic = academicAsync.value; + if (academic != null) { + final suffix = + '${user.supabaseUserId}_${academic.semester}_${academic.year}'; + await Future.wait([ + storage.deleteCachedData('dashboard_courses_$suffix'), + storage.deleteCachedData('dashboard_attendance_$suffix'), + storage.deleteCachedData('dashboard_instructors_$suffix'), + ]); + } } } - // 3. Refresh Tracking (Official Report + Tracker Records) - // We don't need forceSync: true here because we already triggered it above - await ref.read(trackingProvider.notifier).refresh(); - // 4. Force a rebuild of the dashboard with fresh data // We clear local caches to ensure we don't return stale combined data _cachedCourses = null; @@ -570,17 +604,86 @@ class DashboardNotifier extends AsyncNotifier { _lastAcademic = null; _needsRevalidate = true; - state = await AsyncValue.guard(() async { - final academicAsync = ref.read(academicProvider); - final academic = academicAsync.value; - if (academic == null) throw Exception('No academic context'); + ref.invalidateSelf(); + await future; - final trackingState = await ref.read(trackingProvider.future); - final tracking = trackingState.groupedByCourse.values - .expand((e) => e) - .toList(); - return _fetchAndProcess(tracking, academic, trackingState.officialReport); - }); + if (refreshError != null) { + Error.throwWithStackTrace( + refreshError, + refreshStackTrace ?? StackTrace.current, + ); + } + } + + Future refreshAfterCourseAdded() async { + final user = ref.read(authProvider).value; + final academic = ref.read(academicProvider).value; + if (user != null && academic != null) { + final storage = ref.read(secureStorageProvider); + final suffix = + '${user.supabaseUserId}_${academic.semester}_${academic.year}'; + await storage.deleteCachedData('dashboard_courses_$suffix'); + } + _cachedCourses = null; + _needsRevalidate = true; + ref.invalidateSelf(); + await future; + } + + Future updateLocalInstructor( + String courseCode, + String instructorName, + ) async { + final user = ref.read(authProvider).value; + final academic = ref.read(academicProvider).value; + if (user == null || academic == null) return; + + final stdCode = courseCode.toUpperCase().replaceAll(' ', ''); + + // 1. Update in-memory cache + final updatedList = List.from(_cachedInstructors ?? []); + final index = updatedList.indexWhere( + (i) => i.courseCode.toUpperCase().replaceAll(' ', '') == stdCode, + ); + + final updatedInstructor = CourseInstructor( + courseCode: courseCode, + instructorName: instructorName, + ); + + if (index >= 0) { + updatedList[index] = updatedInstructor; + } else { + updatedList.add(updatedInstructor); + } + _cachedInstructors = updatedList; + + // 2. Persist to disk cache + final storage = ref.read(secureStorageProvider); + final cacheKeySuffix = + '${user.supabaseUserId}_${academic.semester}_${academic.year}'; + await storage.saveCachedData( + 'dashboard_instructors_$cacheKeySuffix', + updatedList.map((i) => i.toJson()).toList(), + ); + + // 3. Update active Riverpod state if it has data + if (state.hasValue) { + final currentData = state.value!; + state = AsyncValue.data( + DashboardData( + courses: currentData.courses, + attendance: currentData.attendance, + tracking: currentData.tracking, + stats: currentData.stats, + selectedSemester: currentData.selectedSemester, + selectedYear: currentData.selectedYear, + instructors: updatedList, + className: currentData.className, + disabledCodes: currentData.disabledCodes, + ), + ); + } } Future setSemester(String sem) async { diff --git a/mobile/lib/providers/leave_provider.dart b/mobile/lib/providers/leave_provider.dart index 0018dd8f..f0c23b20 100644 --- a/mobile/lib/providers/leave_provider.dart +++ b/mobile/lib/providers/leave_provider.dart @@ -24,7 +24,13 @@ class LeaveNotifier extends AsyncNotifier { @override FutureOr build() async { final authState = ref.watch(authProvider); - final academic = ref.watch(academicProvider).value; + final academicAsync = ref.watch(academicProvider); + + if (authState.isLoading || academicAsync.isLoading) { + return Completer().future; + } + + final academic = academicAsync.value; if (authState.value == null || academic == null) return LeaveState.empty(); diff --git a/mobile/lib/providers/notification_provider.dart b/mobile/lib/providers/notification_provider.dart index f68e7ba9..7575be48 100644 --- a/mobile/lib/providers/notification_provider.dart +++ b/mobile/lib/providers/notification_provider.dart @@ -191,10 +191,12 @@ class NotificationsNotifier extends AsyncNotifier { if (current == null || !current.hasNextPage || _isFetchingNextPage) return; _isFetchingNextPage = true; + final page = _currentPage + 1; try { - _currentPage++; - // We don't set state to loading to avoid full-screen spinner. - final nextState = await _fetchNextPage(page: _currentPage); + final nextState = await _fetchNextPage(page: page); + // Only advance the current page after a successful fetch to avoid + // skipping pages when a network call fails. + _currentPage = page; state = AsyncValue.data(nextState); } finally { _isFetchingNextPage = false; @@ -287,7 +289,7 @@ class NotificationsNotifier extends AsyncNotifier { .from('notification') .update({'is_read': newIsRead}) .eq('id', id); - } catch (e) { + } on Object catch (_) { state = AsyncValue.data(previousState); rethrow; } @@ -346,7 +348,7 @@ class NotificationsNotifier extends AsyncNotifier { .update({'is_read': true}) .eq('auth_user_id', userId) .eq('is_read', false); - } catch (e) { + } on Object catch (_) { state = AsyncValue.data(previousState); rethrow; } diff --git a/mobile/lib/providers/score_provider.dart b/mobile/lib/providers/score_provider.dart index 6195f319..6bd08a52 100644 --- a/mobile/lib/providers/score_provider.dart +++ b/mobile/lib/providers/score_provider.dart @@ -71,6 +71,11 @@ class ScoreNotifier extends AsyncNotifier { Future build() async { final authState = ref.watch(authProvider); final academicAsync = ref.watch(academicProvider); + + if (authState.isLoading || academicAsync.isLoading) { + return Completer().future; + } + final academic = academicAsync.value; // BLOCKER: Do not fire queries until Cron Sync is finished @@ -82,7 +87,10 @@ class ScoreNotifier extends AsyncNotifier { return _initialFetch(academic: academic); } - Future _initialFetch({AcademicState? academic}) async { + Future _initialFetch({ + AcademicState? academic, + bool bypassCache = false, + }) async { final authState = ref.watch(authProvider); final user = authState.value; if (user == null) throw Exception('Unauthorized'); @@ -127,6 +135,7 @@ class ScoreNotifier extends AsyncNotifier { questionsMap: questionsMap, answersMap: answersMap, resolvedScores: resolvedScores, + bypassCache: bypassCache, ), ), ); @@ -162,7 +171,7 @@ class ScoreNotifier extends AsyncNotifier { ); return _applyFilter(state, 'all'); - } catch (e) { + } on Object catch (e) { throw Exception('Failed to load internal marks: $e'); } } @@ -210,7 +219,9 @@ class ScoreNotifier extends AsyncNotifier { final academicAsync = ref.read(academicProvider); final academic = academicAsync.value; state = const AsyncValue.loading(); - state = await AsyncValue.guard(() => _initialFetch(academic: academic)); + state = await AsyncValue.guard( + () => _initialFetch(academic: academic, bypassCache: true), + ); } bool _matchesAcademic(Exam exam, AcademicState? academic) { @@ -232,16 +243,22 @@ class ScoreNotifier extends AsyncNotifier { required Map> questionsMap, required Map> answersMap, required Map resolvedScores, + bool bypassCache = false, }) async { final cacheKeyQs = 'exam_questions_${exam.id}'; final cacheKeyAns = 'exam_answers_${exam.id}'; - final cacheResults = await Future.wait([ - storage.getCachedData(cacheKeyQs), - storage.getCachedData(cacheKeyAns), - ]); - dynamic qsData = cacheResults[0]; - dynamic ansData = cacheResults[1]; + dynamic qsData; + dynamic ansData; + + if (!bypassCache) { + final cacheResults = await Future.wait([ + storage.getCachedData(cacheKeyQs), + storage.getCachedData(cacheKeyAns), + ]); + qsData = cacheResults[0]; + ansData = cacheResults[1]; + } if (qsData == null || ansData == null) { final results = await Future.wait([ diff --git a/mobile/lib/providers/theme_provider.dart b/mobile/lib/providers/theme_provider.dart index e499c78a..68584552 100644 --- a/mobile/lib/providers/theme_provider.dart +++ b/mobile/lib/providers/theme_provider.dart @@ -12,11 +12,25 @@ final themeProvider = NotifierProvider( /// Manages the persistence and state of the application's theme mode. class ThemeNotifier extends Notifier { static const _key = 'theme_mode'; + static ThemeMode? _preloadedTheme; + + static Future preload() async { + if (_preloadedTheme != null) return; + try { + final prefs = await SharedPreferences.getInstance(); + final index = prefs.getInt(_key); + if (index != null && index < ThemeMode.values.length) { + _preloadedTheme = ThemeMode.values[index]; + } + } on Object catch (e) { + AppLogger.e('ThemeNotifier: Error preloading theme', e); + } + } @override ThemeMode build() { final _ = _loadTheme(); - return ThemeMode.light; + return _preloadedTheme ?? ThemeMode.system; } Future _loadTheme() async { @@ -24,7 +38,9 @@ class ThemeNotifier extends Notifier { final prefs = await SharedPreferences.getInstance(); final index = prefs.getInt(_key); if (index != null && index < ThemeMode.values.length) { - state = ThemeMode.values[index]; + final mode = ThemeMode.values[index]; + _preloadedTheme = mode; + state = mode; } } on Object catch (e) { AppLogger.e('ThemeNotifier: Error loading theme', e); @@ -32,6 +48,7 @@ class ThemeNotifier extends Notifier { } Future setTheme(ThemeMode mode) async { + _preloadedTheme = mode; state = mode; try { final prefs = await SharedPreferences.getInstance(); diff --git a/mobile/lib/providers/tracking_provider.dart b/mobile/lib/providers/tracking_provider.dart index 434e4b75..6a4ea2d1 100644 --- a/mobile/lib/providers/tracking_provider.dart +++ b/mobile/lib/providers/tracking_provider.dart @@ -63,6 +63,11 @@ class TrackingNotifier extends AsyncNotifier { // 1. Reactive Dependency: Clear data immediately on logout OR Semester Change final authState = ref.watch(authProvider); final academicAsync = ref.watch(academicProvider); + + if (authState.isLoading || academicAsync.isLoading) { + return Completer().future; + } + final academic = academicAsync.value; if (authState.value == null || academic == null) { @@ -317,7 +322,7 @@ class TrackingNotifier extends AsyncNotifier { ); } on Object catch (_) {} } - } catch (e) { + } on Object catch (e) { AppLogger.e('TrackingNotifier: Failed to insert record', e); rethrow; } @@ -373,7 +378,7 @@ class TrackingNotifier extends AsyncNotifier { } on Object catch (_) {} } } - } catch (e) { + } on Object catch (e) { AppLogger.e('TrackingNotifier: Failed to delete record', e); rethrow; } @@ -421,7 +426,7 @@ class TrackingNotifier extends AsyncNotifier { await query; await refresh(); - } catch (e) { + } on Object catch (e) { AppLogger.e('TrackingNotifier: Failed to clear records', e); rethrow; } diff --git a/mobile/lib/router/app_router.dart b/mobile/lib/router/app_router.dart index 34be8a12..fb5cea70 100644 --- a/mobile/lib/router/app_router.dart +++ b/mobile/lib/router/app_router.dart @@ -21,6 +21,7 @@ import 'package:ghostclass/screens/scores_screen.dart'; import 'package:ghostclass/screens/splash_screen.dart'; import 'package:ghostclass/screens/tracking_screen.dart'; import 'package:ghostclass/services/analytics_service.dart'; +import 'package:ghostclass/services/logger.dart'; import 'package:go_router/go_router.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; @@ -39,7 +40,14 @@ class GoRouterRefreshStream extends ChangeNotifier { @override void dispose() { - unawaited(_subscription.cancel()); + try { + AppLogger.safeUnawait( + _subscription.cancel(), + 'GoRouterRefreshStream.cancel', + ); + } on Object catch (e) { + debugPrint('GoRouterRefreshStream: cancel threw: $e'); + } super.dispose(); } } @@ -76,11 +84,19 @@ final routerProvider = Provider((ref) { } }); + final refreshStream = GoRouterRefreshStream( + Supabase.instance.client.auth.onAuthStateChange, + ); + ref.onDispose(() { + refreshStream.dispose(); + authRefreshNotifier.dispose(); + }); + return GoRouter( navigatorKey: _rootNavigatorKey, initialLocation: '/', refreshListenable: Listenable.merge([ - GoRouterRefreshStream(Supabase.instance.client.auth.onAuthStateChange), + refreshStream, authRefreshNotifier, ]), observers: [SentryNavigatorObserver(), AnalyticsService.instance.observer], diff --git a/mobile/lib/screens/accept_terms_screen.dart b/mobile/lib/screens/accept_terms_screen.dart index af397df5..ff65826e 100644 --- a/mobile/lib/screens/accept_terms_screen.dart +++ b/mobile/lib/screens/accept_terms_screen.dart @@ -31,7 +31,18 @@ class _AcceptTermsScreenState extends ConsumerState { } }); try { - unawaited(AnalyticsService.instance.logScreenView('accept_terms')); + AppLogger.safeUnawait( + AnalyticsService.instance + .logScreenView('accept_terms') + .catchError( + (Object e, StackTrace st) => AppLogger.e( + 'AcceptTermsScreen: analytics logScreenView failed', + e, + st, + ), + ), + 'AcceptTermsScreen: analytics logScreenView', + ); } on Object catch (_) {} } @@ -43,10 +54,19 @@ class _AcceptTermsScreenState extends ConsumerState { try { try { - unawaited( - AnalyticsService.instance.logCustom('accept_terms_tap', { - 'version': _termsVersion, - }), + AppLogger.safeUnawait( + AnalyticsService.instance + .logCustom('accept_terms_tap', { + 'version': _termsVersion, + }) + .catchError( + (Object e, StackTrace st) => AppLogger.e( + 'AcceptTermsScreen: analytics accept_terms_tap failed', + e, + st, + ), + ), + 'AcceptTermsScreen: analytics accept_terms_tap', ); } on Object catch (_) {} await ref.read(authProvider.notifier).acceptTerms(); @@ -218,10 +238,11 @@ class _AcceptTermsScreenState extends ConsumerState { const SizedBox(height: 20), // Full Policy Link (Moved outside) - GestureDetector( + InkWell( onTap: () { final _ = context.push('/legal'); }, + borderRadius: BorderRadius.circular(12), child: Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( @@ -277,8 +298,9 @@ class _AcceptTermsScreenState extends ConsumerState { const SizedBox(height: 32), // Acceptance - GestureDetector( + InkWell( onTap: () => setState(() => _accepted = !_accepted), + borderRadius: BorderRadius.circular(16), child: Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( diff --git a/mobile/lib/screens/attendance_calendar_screen.dart b/mobile/lib/screens/attendance_calendar_screen.dart index 6d720aeb..fc57b9f8 100644 --- a/mobile/lib/screens/attendance_calendar_screen.dart +++ b/mobile/lib/screens/attendance_calendar_screen.dart @@ -1,8 +1,10 @@ import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:ghostclass/logic/attendance_utils.dart' as utils; import 'package:ghostclass/logic/error_handler.dart'; +import 'package:ghostclass/logic/error_utils.dart'; import 'package:ghostclass/models/attendance.dart'; import 'package:ghostclass/models/course_details.dart'; import 'package:ghostclass/providers/academic_provider.dart'; @@ -283,14 +285,21 @@ class _CalendarContent extends ConsumerWidget { final events = _getEventsForDay(selectedDay, disabledCodes, context); - final canMovePrev = - focusedDay.year > startDate.year || focusedDay.month > startDate.month; - final canMoveNext = - focusedDay.year < endDate.year || focusedDay.month < endDate.month; + final prevMonthLastDay = DateTime(focusedDay.year, focusedDay.month, 0); + final canMovePrev = !prevMonthLastDay.isBefore(startDate); + + final nextMonthFirstDay = DateTime(focusedDay.year, focusedDay.month + 1); + final canMoveNext = !nextMonthFirstDay.isAfter(endDate); return ServiceRefreshIndicator( onRefresh: () async { - await ref.read(dashboardProvider.notifier).refresh(); + try { + await ref.read(dashboardProvider.notifier).refresh(); + } on Object { + if (!context.mounted) rethrow; + ServiceToast.show(context, 'Refresh failed', isError: true); + rethrow; + } }, child: CustomScrollView( controller: scrollController, @@ -308,7 +317,7 @@ class _CalendarContent extends ConsumerWidget { } else { ServiceToast.show( context, - 'Reached start of ${dashboard.selectedSemester.toUpperCase()} ${dashboard.selectedYear}', + 'Start of ${dashboard.selectedSemester.toUpperCase()} semester reached.', ); } }, @@ -318,7 +327,7 @@ class _CalendarContent extends ConsumerWidget { } else { ServiceToast.show( context, - 'Reached end of ${dashboard.selectedSemester.toUpperCase()} ${dashboard.selectedYear}', + 'End of ${dashboard.selectedSemester.toUpperCase()} semester reached.', ); } }, @@ -414,195 +423,206 @@ class _CalendarContent extends ConsumerWidget { : 'Enter reason for being Present...'; ref.read(uiModalOpenProvider.notifier).setOpen(true); - final _ = showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(32)), - ), - builder: (context) { - var isSubmitting = false; - return StatefulBuilder( - builder: (context, setState) { - return Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom, - left: 24, - right: 24, - top: 12, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Center( - child: Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(2), - ), - ), - ), - const SizedBox(height: 24), - Text( - 'Mark as ${initialStatus == 'dutyLeave' ? "Duty Leave" : "Present"}', - style: GoogleFonts.manrope( - fontSize: 20, - fontWeight: FontWeight.w900, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - const SizedBox(height: 8), - Text( - 'Enter a remark for this correction:', - style: GoogleFonts.manrope( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.6), - fontSize: 13, - ), + final _ = + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(32)), + ), + builder: (context) { + var isSubmitting = false; + return StatefulBuilder( + builder: (context, setState) { + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + left: 24, + right: 24, + top: 12, ), - const SizedBox(height: 20), - TextField( - controller: controller, - autofocus: true, - style: GoogleFonts.manrope( - color: Theme.of(context).colorScheme.onSurface, - ), - decoration: InputDecoration( - hintText: hint, - hintStyle: GoogleFonts.manrope( - color: Theme.of(context).colorScheme.onSurface - .withValues( - alpha: 0.4, - ), - fontSize: 13, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(2), + ), + ), ), - filled: true, - fillColor: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.05), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: BorderSide.none, + const SizedBox(height: 24), + Text( + 'Mark as ${initialStatus == 'dutyLeave' ? "Duty Leave" : "Present"}', + style: GoogleFonts.manrope( + fontSize: 20, + fontWeight: FontWeight.w900, + color: Theme.of(context).colorScheme.onSurface, + ), ), - contentPadding: const EdgeInsets.all(16), - ), - ), - const SizedBox(height: 24), - Row( - children: [ - Expanded( - child: TextButton( - onPressed: isSubmitting - ? null - : () => Navigator.pop(context), - child: Text( - 'CANCEL', - style: GoogleFonts.manrope( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.4), - fontWeight: FontWeight.w800, - letterSpacing: 1, - ), + const SizedBox(height: 8), + Text( + 'Enter a remark for this correction:', + style: GoogleFonts.manrope( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.6), + fontSize: 13, + ), + ), + const SizedBox(height: 20), + TextField( + controller: controller, + autofocus: true, + style: GoogleFonts.manrope( + color: Theme.of(context).colorScheme.onSurface, + ), + decoration: InputDecoration( + hintText: hint, + hintStyle: GoogleFonts.manrope( + color: Theme.of(context).colorScheme.onSurface + .withValues( + alpha: 0.4, + ), + fontSize: 13, + ), + filled: true, + fillColor: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.05), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide.none, ), + contentPadding: const EdgeInsets.all(16), ), ), - const SizedBox(width: 12), - Expanded( - child: Consumer( - builder: (context, ref, _) { - return ElevatedButton( + const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: TextButton( onPressed: isSubmitting ? null - : () async { - final remark = controller.text.trim(); - final finalRemark = remark.isEmpty - ? (attendance == 225 - ? 'Duty Leave' - : 'Self-Marked: Present') - : remark; - - setState(() => isSubmitting = true); - - try { - await ref - .read(trackingProvider.notifier) - .insertRecord( - courseId: event.courseId, - date: event.dbDate, - session: event.displaySessionName, - attendance: attendance, - status: 'correction', - remarks: finalRemark, - ); - if (context.mounted) { - Navigator.pop(context); - ServiceToast.show( - context, - 'Correction added successfully', - ); - } - } on Object { - if (context.mounted) { - setState(() => isSubmitting = false); - ServiceToast.show( - context, - 'Failed to add correction', - isError: true, - ); - } - } - }, - style: ElevatedButton.styleFrom( - backgroundColor: attendance == 225 - ? const Color(0xFFF59E0B) - : const Color(0xFF10B981), - foregroundColor: Colors.white, - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - padding: const EdgeInsets.symmetric( - vertical: 16, + : () => Navigator.pop(context), + child: Text( + 'CANCEL', + style: GoogleFonts.manrope( + color: + Theme.of( + context, + ).colorScheme.onSurface.withValues( + alpha: 0.4, + ), + fontWeight: FontWeight.w800, + letterSpacing: 1, ), ), - child: isSubmitting - ? const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ) - : Text( - 'CONFIRM', - style: GoogleFonts.manrope( - fontWeight: FontWeight.w900, - letterSpacing: 1, - ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Consumer( + builder: (context, ref, _) { + return ElevatedButton( + onPressed: isSubmitting + ? null + : () async { + final remark = controller.text.trim(); + final finalRemark = remark.isEmpty + ? (attendance == 225 + ? 'Duty Leave' + : 'Self-Marked: Present') + : remark; + + setState(() => isSubmitting = true); + + try { + await ref + .read(trackingProvider.notifier) + .insertRecord( + courseId: event.courseId, + date: event.dbDate, + session: + event.displaySessionName, + attendance: attendance, + status: 'correction', + remarks: finalRemark, + ); + if (context.mounted) { + Navigator.pop(context); + ServiceToast.show( + context, + 'Correction added successfully', + ); + } + } on Object catch (e) { + if (context.mounted) { + setState( + () => isSubmitting = false, + ); + ServiceToast.show( + context, + formatApiError(e, 'attendance'), + isError: true, + ); + } + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: attendance == 225 + ? const Color(0xFFF59E0B) + : const Color(0xFF10B981), + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), ), - ); - }, - ), + padding: const EdgeInsets.symmetric( + vertical: 16, + ), + ), + child: isSubmitting + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : Text( + 'CONFIRM', + style: GoogleFonts.manrope( + fontWeight: FontWeight.w900, + letterSpacing: 1, + ), + ), + ); + }, + ), + ), + ], ), + const SizedBox(height: 24), ], ), - const SizedBox(height: 24), - ], - ), + ); + }, ); }, - ); - }, - ).then((_) => ref.read(uiModalOpenProvider.notifier).setOpen(false)); + ).then((_) { + if (context.mounted) { + ref.read(uiModalOpenProvider.notifier).setOpen(false); + } + }); } void _deleteRecord(BuildContext context, WidgetRef ref, int id) { @@ -661,12 +681,12 @@ class _CalendarContent extends ConsumerWidget { 'Record deleted successfully', ); } - } on Object { + } on Object catch (e) { if (context.mounted) { setDialogState(() => isDeleting = false); ServiceToast.show( context, - 'Failed to delete record', + formatApiError(e, 'deleting record'), isError: true, ); } diff --git a/mobile/lib/screens/contact_screen.dart b/mobile/lib/screens/contact_screen.dart index 1c7751d5..66472632 100644 --- a/mobile/lib/screens/contact_screen.dart +++ b/mobile/lib/screens/contact_screen.dart @@ -344,150 +344,160 @@ class _ContactScreenState extends ConsumerState { Widget build(BuildContext context) { final primary = Theme.of(context).colorScheme.primary; return Scaffold( - body: GestureDetector( - onTap: () => FocusManager.instance.primaryFocus?.unfocus(), - child: CustomScrollView( - physics: const BouncingScrollPhysics( - parent: AlwaysScrollableScrollPhysics(), - ), - slivers: [ - SliverAppBar( - pinned: true, - expandedHeight: 160, - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - surfaceTintColor: Colors.transparent, - leading: IconButton( - icon: const Icon(Icons.arrow_back_ios_new_rounded, size: 16), - onPressed: () => Navigator.of(context).pop(), - ), - flexibleSpace: FlexibleSpaceBar( - background: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - primary.withValues(alpha: 0.12), - Colors.transparent, - ], + body: Semantics( + button: true, + label: 'Dismiss keyboard', + child: InkWell( + onTap: () => FocusManager.instance.primaryFocus?.unfocus(), + splashColor: Colors.transparent, + highlightColor: Colors.transparent, + child: CustomScrollView( + physics: const BouncingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), + ), + slivers: [ + SliverAppBar( + pinned: true, + expandedHeight: 160, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + surfaceTintColor: Colors.transparent, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios_new_rounded, size: 16), + onPressed: () => Navigator.of(context).pop(), + ), + flexibleSpace: FlexibleSpaceBar( + background: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + primary.withValues(alpha: 0.12), + Colors.transparent, + ], + ), ), - ), - padding: const EdgeInsets.fromLTRB(24, 48, 24, 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: const Color(0xFF7C3AED).withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(10), - ), - child: Icon( - LucideIcons.send, - color: primary, - size: 20, + padding: const EdgeInsets.fromLTRB(24, 48, 24, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: const Color( + 0xFF7C3AED, + ).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + LucideIcons.send, + color: primary, + size: 20, + ), ), - ), - const SizedBox(height: 12), - Text( - 'Contact Us', - style: GoogleFonts.manrope( - fontSize: 22, - fontWeight: FontWeight.w800, - color: Theme.of(context).colorScheme.onSurface, - letterSpacing: -0.6, - height: 1.1, + const SizedBox(height: 12), + Text( + 'Contact Us', + style: GoogleFonts.manrope( + fontSize: 22, + fontWeight: FontWeight.w800, + color: Theme.of(context).colorScheme.onSurface, + letterSpacing: -0.6, + height: 1.1, + ), ), - ), - ], + ], + ), ), ), ), - ), - SliverPadding( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 24), - sliver: SliverList( - delegate: SliverChildListDelegate([ - Text( - "Our team is happy to help. Fill in the form below and we'll get back to you within 24–48 hours.", - style: TextStyle( - fontSize: 13, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.85), - height: 1.5, - ), - ), - const SizedBox(height: 24), - _field( - label: 'Name', - controller: _nameController, - hint: 'Your full name', - autofillHints: [AutofillHints.name], - ), - _field( - label: 'Email', - controller: _emailController, - hint: 'cooked_fr@attendance.edu', - keyboardType: TextInputType.emailAddress, - autofillHints: [AutofillHints.email], - ), - _field( - label: 'Subject', - controller: _subjectController, - hint: 'How can we help?', - ), - _field( - label: 'Message', - controller: _messageController, - hint: 'Describe your issue or question...', - keyboardType: TextInputType.multiline, - action: TextInputAction.newline, - maxLines: 5, - ), - const SizedBox(height: 4), - SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: _sending - ? null - : () { - final _ = _handleSend(); - }, - icon: _sending - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ) - : const Icon(LucideIcons.send, size: 15), - label: Text(_sending ? 'Sending...' : 'Send Message'), - style: ElevatedButton.styleFrom( - backgroundColor: primary, - foregroundColor: Colors.white, - disabledBackgroundColor: primary.withValues(alpha: 0.5), - disabledForegroundColor: Theme.of( + SliverPadding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 24), + sliver: SliverList( + delegate: SliverChildListDelegate([ + Text( + "Our team is happy to help. Fill in the form below and we'll get back to you within 24–48 hours.", + style: TextStyle( + fontSize: 13, + color: Theme.of( context, - ).colorScheme.onSurface.withValues(alpha: 0.7), - padding: const EdgeInsets.symmetric(vertical: 15), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - textStyle: GoogleFonts.manrope( - fontSize: 15, - fontWeight: FontWeight.w700, + ).colorScheme.onSurface.withValues(alpha: 0.85), + height: 1.5, + ), + ), + const SizedBox(height: 24), + _field( + label: 'Name', + controller: _nameController, + hint: 'Your full name', + autofillHints: [AutofillHints.name], + ), + _field( + label: 'Email', + controller: _emailController, + hint: 'cooked_fr@attendance.edu', + keyboardType: TextInputType.emailAddress, + autofillHints: [AutofillHints.email], + ), + _field( + label: 'Subject', + controller: _subjectController, + hint: 'How can we help?', + ), + _field( + label: 'Message', + controller: _messageController, + hint: 'Describe your issue or question...', + keyboardType: TextInputType.multiline, + action: TextInputAction.newline, + maxLines: 5, + ), + const SizedBox(height: 4), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _sending + ? null + : () { + final _ = _handleSend(); + }, + icon: _sending + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(LucideIcons.send, size: 15), + label: Text(_sending ? 'Sending...' : 'Send Message'), + style: ElevatedButton.styleFrom( + backgroundColor: primary, + foregroundColor: Colors.white, + disabledBackgroundColor: primary.withValues( + alpha: 0.5, + ), + disabledForegroundColor: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.7), + padding: const EdgeInsets.symmetric(vertical: 15), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + textStyle: GoogleFonts.manrope( + fontSize: 15, + fontWeight: FontWeight.w700, + ), ), ), ), - ), - ]), + ]), + ), ), - ), - ], + ], + ), ), ), ); diff --git a/mobile/lib/screens/dashboard_screen.dart b/mobile/lib/screens/dashboard_screen.dart index 51ed6ba4..ce91f752 100644 --- a/mobile/lib/screens/dashboard_screen.dart +++ b/mobile/lib/screens/dashboard_screen.dart @@ -4,6 +4,7 @@ import 'package:ghostclass/providers/auth_provider.dart'; import 'package:ghostclass/providers/dashboard_provider.dart'; import 'package:ghostclass/services/api_service.dart'; import 'package:ghostclass/services/logger.dart'; +import 'package:ghostclass/widgets/dashboard/class_selection_dialog.dart'; import 'package:ghostclass/widgets/dashboard/course_list_section.dart'; import 'package:ghostclass/widgets/dashboard/header_section.dart'; import 'package:ghostclass/widgets/dashboard/progress_section.dart'; @@ -12,6 +13,7 @@ import 'package:ghostclass/widgets/dashboard/trend_chart.dart'; import 'package:ghostclass/widgets/loading_overlay.dart'; import 'package:ghostclass/widgets/service_error_view.dart'; import 'package:ghostclass/widgets/service_refresh_indicator.dart'; +import 'package:ghostclass/widgets/service_toast.dart'; class DashboardScreen extends ConsumerStatefulWidget { const DashboardScreen({super.key}); @@ -21,13 +23,38 @@ class DashboardScreen extends ConsumerStatefulWidget { } class _DashboardScreenState extends ConsumerState { + bool _isDialogOpen = false; + + void _checkAndShowClassDialog() { + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!mounted || _isDialogOpen) return; + final user = ref.read(authProvider).value; + if (user == null) return; + final profile = user.profile; + final isSyncing = user.isSyncing; + + if (!isSyncing && + (profile?.classField?.id == null || + profile!.classField!.id.isEmpty)) { + setState(() => _isDialogOpen = true); + await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => const ClassSelectionDialog(), + ); + if (mounted) { + setState(() => _isDialogOpen = false); + } + } + }); + } + @override Widget build(BuildContext context) { final dashboardState = ref.watch(dashboardProvider); final data = dashboardState.value; - final isSyncing = ref.watch( - authProvider.select((v) => v.value?.isSyncing ?? false), - ); + final user = ref.watch(authProvider).value; + final isSyncing = user?.isSyncing ?? false; if (dashboardState.isLoading || isSyncing) { return Scaffold( @@ -58,6 +85,10 @@ class _DashboardScreenState extends ConsumerState { ); } + if (user != null && !isSyncing) { + _checkAndShowClassDialog(); + } + return Scaffold( backgroundColor: Theme.of(context).scaffoldBackgroundColor, body: _DashboardContent(data: data), @@ -79,7 +110,13 @@ class _DashboardContent extends ConsumerWidget { return ServiceRefreshIndicator( onRefresh: () async { - await ref.read(dashboardProvider.notifier).refresh(); + try { + await ref.read(dashboardProvider.notifier).refresh(); + } on Object { + if (!context.mounted) rethrow; + ServiceToast.show(context, 'Refresh failed', isError: true); + rethrow; + } }, child: CustomScrollView( physics: const BouncingScrollPhysics( diff --git a/mobile/lib/screens/ghostclass_screen.dart b/mobile/lib/screens/ghostclass_screen.dart index 50bcb2dc..52b6b1dc 100644 --- a/mobile/lib/screens/ghostclass_screen.dart +++ b/mobile/lib/screens/ghostclass_screen.dart @@ -11,6 +11,7 @@ import 'package:ghostclass/widgets/ghostclass/ghostclass_footer.dart'; import 'package:ghostclass/widgets/ghostclass/ghostclass_menu_tile.dart'; import 'package:ghostclass/widgets/ghostclass/ghostclass_settings_card.dart'; import 'package:ghostclass/widgets/loading_overlay.dart'; +import 'package:ghostclass/widgets/service_toast.dart'; import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; @@ -93,17 +94,38 @@ class GhostClassScreen extends ConsumerWidget { showToggle: true, toggleValue: user.settings.bunkCalculatorEnabled, isDisabled: user.isUpdatingSettings, - onToggle: (val) { - final _ = ref - .read(authProvider.notifier) - .updateSettings(bunkEnabled: val); + onToggle: (val) async { + try { + await ref + .read(authProvider.notifier) + .updateSettings(bunkEnabled: val); + } on Object catch (_) { + if (context.mounted) { + ServiceToast.show( + context, + 'Failed to update settings', + isError: true, + ); + } + } }, - onTap: () { - final _ = ref - .read(authProvider.notifier) - .updateSettings( - bunkEnabled: !user.settings.bunkCalculatorEnabled, + onTap: () async { + try { + await ref + .read(authProvider.notifier) + .updateSettings( + bunkEnabled: + !user.settings.bunkCalculatorEnabled, + ); + } on Object catch (_) { + if (context.mounted) { + ServiceToast.show( + context, + 'Failed to update settings', + isError: true, ); + } + } }, ), // Theme Switcher @@ -326,192 +348,198 @@ class GhostClassScreen extends ConsumerWidget { Future _handleDeleteAccount(BuildContext context, WidgetRef ref) async { final controller = TextEditingController(); - final confirmed = await showDialog( - context: context, - barrierDismissible: false, - builder: (context) => StatefulBuilder( - builder: (context, setState) => AlertDialog( - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(24), - ), - title: Row( - children: [ - const Icon( - LucideIcons.alertTriangle, - color: Colors.redAccent, - size: 22, - ), - const SizedBox(width: 12), - Expanded( - child: Text( - 'Are you absolutely sure?', - style: GoogleFonts.manrope( - color: Theme.of(context).colorScheme.onSurface, - fontWeight: FontWeight.w900, - ), - ), - ), - ], - ), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + try { + final confirmed = await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => StatefulBuilder( + builder: (context, setState) => AlertDialog( + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + title: Row( children: [ - RichText( - text: TextSpan( + const Icon( + LucideIcons.alertTriangle, + color: Colors.redAccent, + size: 22, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Are you absolutely sure?', style: GoogleFonts.manrope( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.85), - fontSize: 13, - height: 1.5, - fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurface, + fontWeight: FontWeight.w900, ), - children: [ - const TextSpan(text: 'This will permanently erase your '), - TextSpan( - text: 'GhostClass', - style: TextStyle( - color: Theme.of(context).colorScheme.onSurface, - fontWeight: FontWeight.bold, - ), - ), - const TextSpan( - text: - ' account, including all attendance logs and personal settings.\n\n', - ), - TextSpan( - text: - 'Note: Your official EzyGo account remains unaffected.', - style: TextStyle( - fontSize: 11, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.7), - ), - ), - ], ), ), - const SizedBox(height: 24), - RichText( - text: TextSpan( - style: GoogleFonts.manrope( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.6), - fontSize: 12, - fontWeight: FontWeight.w600, - ), - children: [ - const TextSpan(text: 'Type '), - TextSpan( - text: 'DELETE', - style: TextStyle( - color: Theme.of(context).colorScheme.onSurface, - fontWeight: FontWeight.w900, - ), + ], + ), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + RichText( + text: TextSpan( + style: GoogleFonts.manrope( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.85), + fontSize: 13, + height: 1.5, + fontWeight: FontWeight.w500, ), - const TextSpan(text: ' to confirm'), - ], + children: [ + const TextSpan( + text: 'This will permanently erase your ', + ), + TextSpan( + text: 'GhostClass', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + fontWeight: FontWeight.bold, + ), + ), + const TextSpan( + text: + ' account, including all attendance logs and personal settings.\n\n', + ), + TextSpan( + text: + 'Note: Your official EzyGo account remains unaffected.', + style: TextStyle( + fontSize: 11, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.7), + ), + ), + ], + ), ), - ), - const SizedBox(height: 8), - TextField( - controller: controller, - autofocus: true, - onChanged: (val) => setState(() {}), - style: GoogleFonts.manrope( - color: Theme.of(context).colorScheme.onSurface, - fontWeight: FontWeight.bold, + const SizedBox(height: 24), + RichText( + text: TextSpan( + style: GoogleFonts.manrope( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.6), + fontSize: 12, + fontWeight: FontWeight.w600, + ), + children: [ + const TextSpan(text: 'Type '), + TextSpan( + text: 'DELETE', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + fontWeight: FontWeight.w900, + ), + ), + const TextSpan(text: ' to confirm'), + ], + ), ), - decoration: InputDecoration( - hintText: 'DELETE', - hintStyle: GoogleFonts.manrope( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.1), + const SizedBox(height: 8), + TextField( + controller: controller, + autofocus: true, + onChanged: (val) => setState(() {}), + style: GoogleFonts.manrope( + color: Theme.of(context).colorScheme.onSurface, + fontWeight: FontWeight.bold, ), - filled: true, - fillColor: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.05), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( + decoration: InputDecoration( + hintText: 'DELETE', + hintStyle: GoogleFonts.manrope( color: Theme.of( context, ).colorScheme.onSurface.withValues(alpha: 0.1), ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: Colors.redAccent), + filled: true, + fillColor: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.05), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.1), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Colors.redAccent), + ), ), ), - ), - ], + ], + ), ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: Text( - 'Cancel', - style: GoogleFonts.manrope( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.7), - fontWeight: FontWeight.w600, + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text( + 'Cancel', + style: GoogleFonts.manrope( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.7), + fontWeight: FontWeight.w600, + ), ), ), - ), - Padding( - padding: const EdgeInsets.only(right: 8), - child: ElevatedButton( - onPressed: controller.text.trim() == 'DELETE' - ? () => Navigator.pop(context, true) - : null, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.redAccent, - foregroundColor: Colors.white, - disabledBackgroundColor: Colors.redAccent.withValues( - alpha: 0.1, + Padding( + padding: const EdgeInsets.only(right: 8), + child: ElevatedButton( + onPressed: controller.text.trim() == 'DELETE' + ? () => Navigator.pop(context, true) + : null, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.redAccent, + foregroundColor: Colors.white, + disabledBackgroundColor: Colors.redAccent.withValues( + alpha: 0.1, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), + child: Text( + 'Permanently Delete', + style: GoogleFonts.manrope(fontWeight: FontWeight.w900), ), ), - child: Text( - 'Permanently Delete', - style: GoogleFonts.manrope(fontWeight: FontWeight.w900), - ), ), - ), - ], + ], + ), ), - ), - ); + ); - if (confirmed == true) { - if (!context.mounted) return; - LoadingOverlay.show(context, message: 'Purging your mortal data... 💀'); - try { - await ref.read(authProvider.notifier).deleteAccount(); - } on Object catch (e, st) { - AppLogger.e('GhostClassScreen: Account deletion failed', e, st); - if (context.mounted) { - Navigator.of(context, rootNavigator: true).pop(); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Failed to delete account. Please try again.'), - backgroundColor: Colors.red, - ), - ); + if (confirmed == true) { + if (!context.mounted) return; + LoadingOverlay.show(context, message: 'Purging your mortal data... 💀'); + try { + await ref.read(authProvider.notifier).deleteAccount(); + } on Object catch (e, st) { + AppLogger.e('GhostClassScreen: Account deletion failed', e, st); + if (context.mounted) { + Navigator.of(context, rootNavigator: true).pop(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Failed to delete account. Please try again.'), + backgroundColor: Colors.red, + ), + ); + } } } + } finally { + controller.dispose(); } } @@ -630,11 +658,21 @@ class GhostClassScreen extends ConsumerWidget { SizedBox( width: double.infinity, child: ElevatedButton( - onPressed: () { - final _ = ref - .read(authProvider.notifier) - .updateSettings(targetPercentage: localTarget); - Navigator.pop(context); + onPressed: () async { + try { + await ref + .read(authProvider.notifier) + .updateSettings(targetPercentage: localTarget); + if (context.mounted) Navigator.pop(context); + } on Object catch (_) { + if (context.mounted) { + ServiceToast.show( + context, + 'Failed to update target', + isError: true, + ); + } + } }, style: ElevatedButton.styleFrom( backgroundColor: primary, diff --git a/mobile/lib/screens/leaves_screen.dart b/mobile/lib/screens/leaves_screen.dart index 4bd46762..a5f47610 100644 --- a/mobile/lib/screens/leaves_screen.dart +++ b/mobile/lib/screens/leaves_screen.dart @@ -1,11 +1,15 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:ghostclass/models/leave.dart'; +import 'package:ghostclass/providers/auth_provider.dart'; import 'package:ghostclass/providers/leave_provider.dart'; +import 'package:ghostclass/services/api_service.dart'; +import 'package:ghostclass/services/refresh_coordinator.dart'; import 'package:ghostclass/theme/app_theme.dart'; import 'package:ghostclass/widgets/loading_overlay.dart'; import 'package:ghostclass/widgets/service_error_view.dart'; import 'package:ghostclass/widgets/service_refresh_indicator.dart'; +import 'package:ghostclass/widgets/service_toast.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:intl/intl.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; @@ -69,7 +73,32 @@ class LeavesScreen extends ConsumerWidget { ), ServiceRefreshIndicator( - onRefresh: () => ref.read(leaveProvider.notifier).refresh(), + onRefresh: () async { + try { + await runUnifiedPullToRefresh( + logLabel: 'LeavesScreen', + refreshProfile: () => ref + .read(authProvider.notifier) + .refreshProfile(force: true), + syncCron: () async { + final supabaseToken = ref + .read(supabaseClientProvider) + .auth + .currentSession + ?.accessToken; + if (supabaseToken == null) return; + await ref + .read(apiServiceProvider) + .triggerSync(supabaseToken, force: true); + }, + refreshData: () => ref.read(leaveProvider.notifier).refresh(), + ); + } on Object { + if (!context.mounted) rethrow; + ServiceToast.show(context, 'Refresh failed', isError: true); + rethrow; + } + }, child: CustomScrollView( physics: const BouncingScrollPhysics( parent: AlwaysScrollableScrollPhysics(), @@ -97,7 +126,25 @@ class LeavesScreen extends ConsumerWidget { error: (err, stack) => SliverFillRemaining( child: ServiceErrorView( error: err, - onRetry: () => ref.read(leaveProvider.notifier).refresh(), + onRetry: () => runUnifiedPullToRefresh( + logLabel: 'LeavesScreen', + refreshProfile: () => ref + .read(authProvider.notifier) + .refreshProfile(force: true), + syncCron: () async { + final supabaseToken = ref + .read(supabaseClientProvider) + .auth + .currentSession + ?.accessToken; + if (supabaseToken == null) return; + await ref + .read(apiServiceProvider) + .triggerSync(supabaseToken, force: true); + }, + refreshData: () => + ref.read(leaveProvider.notifier).refresh(), + ), ), ), ), @@ -752,6 +799,12 @@ class LeavesScreen extends ConsumerWidget { return _LeaveStatus('Pending', Colors.amber, LucideIcons.clock); } + // If any level has rejected, the entire leave is immediately Rejected + final hasRejected = approvers.any((a) => a.actionType == 'reject'); + if (hasRejected) { + return _LeaveStatus('Rejected', Colors.red, LucideIcons.xCircle); + } + final actedApprovers = approvers .where((a) => a.actionType != null || a.actionAt != null) @@ -762,11 +815,27 @@ class LeavesScreen extends ConsumerWidget { return _LeaveStatus('Pending', Colors.amber, LucideIcons.clock); } + final hasPending = approvers.any( + (a) => a.actionType == null && a.actionAt == null, + ); final lastAction = actedApprovers.first.actionType; - if (lastAction == 'reject') { - return _LeaveStatus('Rejected', Colors.red, LucideIcons.xCircle); + if (hasPending) { + // If there are pending steps, we cannot be fully Approved. + // If the last active step approved, the status is "In Progress" (awaiting next level). + if (lastAction == 'approve') { + return _LeaveStatus('In Progress', Colors.lightBlue, LucideIcons.clock); + } + if (lastAction == 'forward') { + return _LeaveStatus('Forwarded', Colors.indigo, LucideIcons.arrowRight); + } + if (lastAction == 'recommend') { + return _LeaveStatus('Recommended', Colors.blue, LucideIcons.arrowRight); + } + return _LeaveStatus('In Progress', Colors.lightBlue, LucideIcons.clock); } + + // All levels have acted, and none rejected. if (lastAction == 'approve') { return _LeaveStatus( 'Approved', diff --git a/mobile/lib/screens/legal_screen.dart b/mobile/lib/screens/legal_screen.dart index 81a3ae22..5e474e42 100644 --- a/mobile/lib/screens/legal_screen.dart +++ b/mobile/lib/screens/legal_screen.dart @@ -5,6 +5,7 @@ import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import 'package:ghostclass/config/app_config.dart'; import 'package:ghostclass/constants/static_content.dart'; +import 'package:ghostclass/services/logger.dart'; import 'package:ghostclass/theme/app_theme.dart'; import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; @@ -201,7 +202,20 @@ class _LegalScreenState extends State { ? MarkdownBody( data: widget.body, onTapLink: (text, href, title) { - if (href != null) unawaited(_launchUrl(href)); + if (href != null) { + AppLogger.safeUnawait( + _launchUrl(href).catchError( + (Object e, StackTrace st) { + AppLogger.e( + 'LegalScreen: launchUrl failed', + e, + st, + ); + }, + ), + 'LegalScreen: launchUrl', + ); + } }, styleSheet: MarkdownStyleSheet( p: GoogleFonts.manrope( diff --git a/mobile/lib/screens/login_screen.dart b/mobile/lib/screens/login_screen.dart index 39d5e73d..ee16399f 100644 --- a/mobile/lib/screens/login_screen.dart +++ b/mobile/lib/screens/login_screen.dart @@ -10,6 +10,7 @@ import 'package:ghostclass/logic/error_handler.dart'; import 'package:ghostclass/providers/app_update_provider.dart'; import 'package:ghostclass/providers/auth_provider.dart'; import 'package:ghostclass/services/analytics_service.dart'; +import 'package:ghostclass/services/logger.dart'; import 'package:ghostclass/services/security_guard.dart'; import 'package:ghostclass/theme/app_theme.dart'; import 'package:ghostclass/widgets/app_footer.dart'; @@ -43,7 +44,7 @@ class _LoginScreenState extends ConsumerState updateState.checkResult!.hasUpdate && !updateState.checkResult!.isForceUpdate && !updateState.dialogDismissed) { - unawaited( + AppLogger.safeUnawait( AppUpdateDialog.show( context, updateState.checkResult!.latestVersion, @@ -53,6 +54,7 @@ class _LoginScreenState extends ConsumerState ref.read(appUpdateProvider.notifier).dismissDialog(); } }), + 'LoginScreen: show update dialog', ); } } @@ -124,10 +126,19 @@ class _LoginScreenState extends ConsumerState try { try { - unawaited( - AnalyticsService.instance.logCustom('login_attempt', { - 'username_length': _usernameController.text.trim().length, - }), + AppLogger.safeUnawait( + AnalyticsService.instance + .logCustom('login_attempt', { + 'username_length': _usernameController.text.trim().length, + }) + .catchError( + (Object e, StackTrace st) => AppLogger.e( + 'LoginScreen: Analytics login_attempt failed', + e, + st, + ), + ), + 'LoginScreen: analytics login_attempt', ); } on Object catch (_) {} @@ -143,10 +154,19 @@ class _LoginScreenState extends ConsumerState if (!mounted) return; LoadingOverlay.hide(context); try { - unawaited( - AnalyticsService.instance.logCustom('login_failed', { - 'reason': e.message, - }), + AppLogger.safeUnawait( + AnalyticsService.instance + .logCustom('login_failed', { + 'reason': e.message, + }) + .catchError( + (Object e, StackTrace st) => AppLogger.e( + 'LoginScreen: Analytics login_failed failed', + e, + st, + ), + ), + 'LoginScreen: analytics login_failed', ); } on Object catch (_) {} await handleError(e.message, title: 'Login Failed'); @@ -154,7 +174,18 @@ class _LoginScreenState extends ConsumerState if (!mounted) return; LoadingOverlay.hide(context); try { - unawaited(AnalyticsService.instance.logError(e.toString())); + AppLogger.safeUnawait( + AnalyticsService.instance + .logError(e.toString()) + .catchError( + (Object e, StackTrace st) => AppLogger.e( + 'LoginScreen: Analytics logError failed', + e, + st, + ), + ), + 'LoginScreen: analytics logError', + ); } on Object catch (_) {} await handleError(e); } finally { @@ -204,8 +235,10 @@ class _LoginScreenState extends ConsumerState ), ), - GestureDetector( + InkWell( onTap: () => FocusScope.of(context).unfocus(), + splashColor: Colors.transparent, + highlightColor: Colors.transparent, child: SafeArea( child: Center( child: SingleChildScrollView( @@ -307,7 +340,7 @@ class _LoginScreenState extends ConsumerState label: 'Forgot password', hint: 'Opens EzyGo website to reset your password', - child: GestureDetector( + child: InkWell( onTap: () async { final url = Uri.parse(AppConfig.ezygoOrigin); if (await canLaunchUrl(url)) { @@ -317,12 +350,18 @@ class _LoginScreenState extends ConsumerState ); } }, - child: Text( - 'Forgot password?', - style: GoogleFonts.manrope( - fontSize: 12, - fontWeight: FontWeight.w700, - color: primaryColor, + borderRadius: BorderRadius.circular(4), + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 2, + ), + child: Text( + 'Forgot password?', + style: GoogleFonts.manrope( + fontSize: 12, + fontWeight: FontWeight.w700, + color: primaryColor, + ), ), ), ), diff --git a/mobile/lib/screens/navigation_shell.dart b/mobile/lib/screens/navigation_shell.dart index c3fa0993..bb634fb0 100644 --- a/mobile/lib/screens/navigation_shell.dart +++ b/mobile/lib/screens/navigation_shell.dart @@ -70,24 +70,42 @@ class _NavigationShellState extends ConsumerState { final updateState = ref.read(appUpdateProvider); if (updateState.checkResult != null && updateState.checkResult!.hasUpdate) { if (updateState.checkResult!.isForceUpdate) { - unawaited( + AppLogger.safeUnawait( AppUpdateDialog.show( context, updateState.checkResult!.latestVersion, isForceUpdate: true, + ).catchError( + (Object e, StackTrace st) => AppLogger.e( + 'NavigationShell: Failed to show force update dialog', + e, + st, + ), ), + 'NavigationShell: show force update dialog', ); } else if (!updateState.dialogDismissed) { - unawaited( + AppLogger.safeUnawait( AppUpdateDialog.show( - context, - updateState.checkResult!.latestVersion, - isForceUpdate: false, - ).then((_) { - if (mounted) { - ref.read(appUpdateProvider.notifier).dismissDialog(); - } - }), + context, + updateState.checkResult!.latestVersion, + isForceUpdate: false, + ) + .then((_) { + if (mounted) { + ref.read(appUpdateProvider.notifier).dismissDialog(); + } + }) + .catchError( + (Object e, StackTrace st) { + AppLogger.e( + 'NavigationShell: Failed to show update dialog', + e, + st, + ); + }, + ), + 'NavigationShell: show update dialog', ); } } @@ -97,14 +115,18 @@ class _NavigationShellState extends ConsumerState { void initState() { super.initState(); + // Run update dialog check after first frame WidgetsBinding.instance.addPostFrameCallback((_) { - _checkAndShowUpdateDialog(); + try { + _checkAndShowUpdateDialog(); + } on Object catch (e, st) { + AppLogger.e('NavigationShell: update dialog check failed', e, st); + } }); + // Subscribe to academic changes and security failures. We keep the + // returned ProviderSubscription objects so we can close them in dispose. _subscriptions.addAll([ - ref.listenManual(appUpdateProvider, (previous, next) { - _checkAndShowUpdateDialog(); - }), ref.listenManual>(academicProvider, ( previous, next, @@ -119,8 +141,16 @@ class _NavigationShellState extends ConsumerState { // Keep calendar dependencies warm in the background whenever the // academic context changes, even if the calendar screen is not open. - unawaited(_prewarmCalendarData()); + AppLogger.safeUnawait( + _prewarmCalendarData().catchError( + (Object e, StackTrace st) { + AppLogger.e('NavigationShell: Prewarm calendar failed', e, st); + }, + ), + 'NavigationShell: prewarm calendar', + ); }), + ref.listenManual(securityFailureProvider, ( previous, next, @@ -129,7 +159,18 @@ class _NavigationShellState extends ConsumerState { !_criticalSecurityLogoutStarted && previous?.criticalRisk != true) { _criticalSecurityLogoutStarted = true; - unawaited(ref.read(authProvider.notifier).logout(force: true)); + AppLogger.safeUnawait( + ref.read(authProvider.notifier).logout(force: true).catchError( + (Object e, StackTrace st) { + AppLogger.e( + 'NavigationShell: Forced logout failed', + e, + st, + ); + }, + ), + 'NavigationShell: forced logout', + ); } }), ]); @@ -202,7 +243,9 @@ class _NavigationShellState extends ConsumerState { ), builder: (context) => const TrackingScreen(), ); - ref.read(uiModalOpenProvider.notifier).setOpen(false); + if (mounted) { + ref.read(uiModalOpenProvider.notifier).setOpen(false); + } } Future showNotificationsOverlay() async { @@ -217,7 +260,9 @@ class _NavigationShellState extends ConsumerState { ), builder: (context) => const NotificationsScreen(), ); - ref.read(uiModalOpenProvider.notifier).setOpen(false); + if (mounted) { + ref.read(uiModalOpenProvider.notifier).setOpen(false); + } } final bottomPadding = MediaQuery.of(context).padding.bottom; @@ -241,7 +286,9 @@ class _NavigationShellState extends ConsumerState { context: context, builder: (context) => const AddAttendanceDialog(), ); - ref.read(uiModalOpenProvider.notifier).setOpen(false); + if (mounted) { + ref.read(uiModalOpenProvider.notifier).setOpen(false); + } } final mainScaffold = Scaffold( @@ -274,8 +321,10 @@ class _NavigationShellState extends ConsumerState { Semantics( button: true, label: 'Notifications', - child: GestureDetector( + child: InkWell( onTap: showNotificationsOverlay, + splashColor: Colors.transparent, + highlightColor: Colors.transparent, child: Stack( clipBehavior: Clip.none, children: [ @@ -344,8 +393,10 @@ class _NavigationShellState extends ConsumerState { Semantics( button: true, label: 'Tracking', - child: GestureDetector( + child: InkWell( onTap: showTrackingOverlay, + splashColor: Colors.transparent, + highlightColor: Colors.transparent, child: Container( width: 36, height: 36, @@ -375,8 +426,10 @@ class _NavigationShellState extends ConsumerState { button: true, label: 'Profile', child: - GestureDetector( + InkWell( onTap: () => context.go('/ghostclass'), + splashColor: Colors.transparent, + highlightColor: Colors.transparent, child: Container( padding: const EdgeInsets.all(2), decoration: BoxDecoration( @@ -528,40 +581,56 @@ class _NavigationShellState extends ConsumerState { return Stack( alignment: Alignment.bottomCenter, children: [ - mainScaffold, + ExcludeSemantics( + excluding: showSecurityBarrier, + child: mainScaffold, + ), // The Semicircle Button (Positioned at bottom center, above everything) Positioned( bottom: 75 + bottomPadding, // Account for bottom safe area/notch - child: IgnorePointer( - ignoring: + child: ExcludeSemantics( + excluding: isModalOpen || (selectedIndex > 1) || showOutageBarrier || showSecurityBarrier, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 200), - opacity: - (isModalOpen || - (selectedIndex > 1) || - showOutageBarrier || - showSecurityBarrier) - ? 0 - : 1, - child: GestureDetector( - onTap: showAddAttendanceDialog, - child: Container( - width: 72, - height: 34, // Reduced height - decoration: BoxDecoration( - color: primary, + child: IgnorePointer( + ignoring: + isModalOpen || + (selectedIndex > 1) || + showOutageBarrier || + showSecurityBarrier, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: + (isModalOpen || + (selectedIndex > 1) || + showOutageBarrier || + showSecurityBarrier) + ? 0 + : 1, + child: Material( + type: MaterialType.transparency, + child: InkWell( + onTap: showAddAttendanceDialog, borderRadius: const BorderRadius.vertical( top: Radius.circular(72), ), - ), - child: Icon( - LucideIcons.plus, - color: Theme.of(context).colorScheme.onPrimary, - size: 24, + child: Container( + width: 72, + height: 34, + decoration: BoxDecoration( + color: primary, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(72), + ), + ), + child: Icon( + LucideIcons.plus, + color: Theme.of(context).colorScheme.onPrimary, + size: 24, + ), + ), ), ), ), @@ -599,7 +668,7 @@ class _NavigationShellState extends ConsumerState { 'NavigationShell: Outage recovery wait completed (or partial success).', ); } on Object catch (e) { - AppLogger.w( + AppLogger.e( 'NavigationShell: Outage recovery wait timed out or failed ($e). Re-enabling UI.', ); } @@ -612,147 +681,180 @@ class _NavigationShellState extends ConsumerState { // --- GLOBAL SECURITY BARRIER --- if (showSecurityBarrier) Positioned.fill( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), - child: Container( - color: Colors.black.withValues(alpha: 0.5), - padding: const EdgeInsets.all(32), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: Colors.red.withValues(alpha: 0.1), - shape: BoxShape.circle, - border: Border.all( - color: Colors.red.withValues(alpha: 0.3), + child: Semantics( + container: true, + liveRegion: true, + label: securityMessage.isEmpty + ? 'Security verification failed.' + : 'Security verification failed. $securityMessage', + child: Stack( + children: [ + const ModalBarrier( + dismissible: false, + color: Colors.black, + semanticsLabel: 'Security verification failed', + ), + BackdropFilter( + filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), + child: Container( + color: Colors.black.withValues(alpha: 0.5), + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.1), + shape: BoxShape.circle, + border: Border.all( + color: Colors.red.withValues(alpha: 0.3), + ), + ), + child: const Icon( + LucideIcons.shieldAlert, + size: 48, + color: Colors.red, + ), + ) + .animate( + onPlay: (controller) => + controller.repeat(reverse: true), + ) + .scale( + begin: const Offset(1, 1), + end: const Offset(1.1, 1.1), + duration: 1000.ms, + ), + const SizedBox(height: 32), + Text( + 'Security Verification Failed', + style: GoogleFonts.manrope( + fontSize: 24, + fontWeight: FontWeight.w800, + color: Colors.white, ), + textAlign: TextAlign.center, ), - child: const Icon( - LucideIcons.shieldAlert, - size: 48, - color: Colors.red, - ), - ) - .animate( - onPlay: (controller) => - controller.repeat(reverse: true), - ) - .scale( - begin: const Offset(1, 1), - end: const Offset(1.1, 1.1), - duration: 1000.ms, - ), - const SizedBox(height: 32), - Text( - 'Security Verification Failed', - style: GoogleFonts.manrope( - fontSize: 24, - fontWeight: FontWeight.w800, - color: Colors.white, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 16), - Text( - securityMessage, - style: GoogleFonts.manrope( - fontSize: 15, - color: Colors.white.withValues(alpha: 0.7), - height: 1.5, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 48), - SizedBox( - width: double.infinity, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: Colors.black, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - ), - onPressed: () async { - if (isCriticalSecurityFailure) { - exit(0); - } - - // Clear lock and retry - ref.read(apiServiceProvider).clearCaches(); - ref - .read(securityFailureProvider.notifier) - .clearFailure(); - try { - await ref - .read(authProvider.notifier) - .refreshProfile(force: true); - } on Object { - // The 401 interceptor will catch it again if it still fails - } - }, - child: Text( - isCriticalSecurityFailure - ? 'Close App' - : 'Restart App', - style: GoogleFonts.manrope( - fontWeight: FontWeight.w700, - ), - ), - ), - ), - const SizedBox(height: 16), - SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - style: OutlinedButton.styleFrom( - foregroundColor: Colors.white, - side: BorderSide( - color: Colors.white.withValues(alpha: 0.3), - ), - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), + const SizedBox(height: 16), + Text( + securityMessage, + style: GoogleFonts.manrope( + fontSize: 15, + color: Colors.white.withValues(alpha: 0.7), + height: 1.5, + ), + textAlign: TextAlign.center, ), - ), - onPressed: () => SupportHelper.contactViaEmail( - subject: - 'Security Failure Report [v${AppConfig.appVersion}]', - customBody: - 'Hi Support,\n\nI encountered a security failure while using the app.\n\n' - '-- SUMMARY --\n' - 'Message: $securityMessage\n\n' - '-- PERSISTENCE --\n' - '${SupportHelper.persistenceMessage}\n', - ), - icon: const Icon(LucideIcons.mail, size: 18), - label: Text( - 'Contact Support', - style: GoogleFonts.manrope( - fontWeight: FontWeight.w700, + const SizedBox(height: 48), + if (!Platform.isIOS || !isCriticalSecurityFailure) + SizedBox( + width: double.infinity, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric( + vertical: 16, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + onPressed: () async { + if (isCriticalSecurityFailure) { + exit(0); + } + + // Clear lock and retry + ref.read(apiServiceProvider).clearCaches(); + ref + .read(securityFailureProvider.notifier) + .clearFailure(); + try { + await ref + .read(authProvider.notifier) + .refreshProfile(force: true); + } on Object { + // The 401 interceptor will catch it again if it still fails + } + }, + child: Text( + isCriticalSecurityFailure + ? 'Close App' + : 'Restart App', + style: GoogleFonts.manrope( + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.black, + ), + ), + ), + ) + else + Text( + 'Please close the app manually.', + style: GoogleFonts.manrope( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.white.withValues(alpha: 0.6), + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: BorderSide( + color: Colors.white.withValues(alpha: 0.3), + ), + padding: const EdgeInsets.symmetric( + vertical: 16, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + onPressed: () => SupportHelper.contactViaEmail( + subject: + 'Security Failure Report [v${AppConfig.appVersion}]', + customBody: + 'Hi Support,\n\nI encountered a security failure while using the app.\n\n' + '-- SUMMARY --\n' + 'Message: $securityMessage\n\n' + '-- PERSISTENCE --\n' + '${SupportHelper.persistenceMessage}\n', + ), + icon: const Icon(LucideIcons.mail, size: 18), + label: Text( + 'Contact Support', + style: GoogleFonts.manrope( + fontWeight: FontWeight.w700, + ), + ), + ), ), - ), + if (!isCriticalSecurityFailure) ...[ + const SizedBox(height: 16), + TextButton( + onPressed: () => + ref.read(authProvider.notifier).logout(), + child: Text( + 'Logout of GhostClass', + style: GoogleFonts.manrope( + color: Colors.red, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ], ), ), - if (!isCriticalSecurityFailure) ...[ - const SizedBox(height: 16), - TextButton( - onPressed: () => - ref.read(authProvider.notifier).logout(), - child: Text( - 'Logout of GhostClass', - style: GoogleFonts.manrope( - color: Colors.red, - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ], - ), + ), + ], ), ), ).animate().fadeIn(duration: 400.ms), @@ -781,9 +883,9 @@ class _NavButton extends StatelessWidget { button: true, label: '$label tab', selected: isSelected, - child: GestureDetector( + child: InkWell( onTap: onTap, - behavior: HitTestBehavior.opaque, + borderRadius: BorderRadius.circular(12), child: AnimatedContainer( duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, diff --git a/mobile/lib/screens/notifications_screen.dart b/mobile/lib/screens/notifications_screen.dart index 578c9721..b7719b61 100644 --- a/mobile/lib/screens/notifications_screen.dart +++ b/mobile/lib/screens/notifications_screen.dart @@ -2,7 +2,11 @@ import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:ghostclass/providers/auth_provider.dart'; import 'package:ghostclass/providers/notification_provider.dart'; +import 'package:ghostclass/services/api_service.dart'; +import 'package:ghostclass/services/logger.dart'; +import 'package:ghostclass/services/refresh_coordinator.dart'; import 'package:ghostclass/widgets/service_refresh_indicator.dart'; import 'package:ghostclass/widgets/service_toast.dart'; import 'package:google_fonts/google_fonts.dart'; @@ -66,7 +70,12 @@ class _NotificationsScreenState extends ConsumerState if (state != null && state.hasNextPage && !ref.read(notificationsProvider).isLoading) { - final _ = notifier.fetchNextPage(); + AppLogger.safeUnawait( + notifier.fetchNextPage().catchError((Object e, StackTrace st) { + AppLogger.e('Notifications: fetchNextPage failed', e, st); + }), + 'Notifications: fetchNextPage', + ); } } } @@ -118,15 +127,25 @@ class _NotificationsScreenState extends ConsumerState child: const Text('Cancel'), ), TextButton( - onPressed: () { - final _ = ref - .read(notificationsProvider.notifier) - .markAllAsRead(); + onPressed: () async { Navigator.pop(context); - ServiceToast.show( - context, - 'All notifications marked as read', - ); + try { + await ref + .read(notificationsProvider.notifier) + .markAllAsRead(); + if (!context.mounted) return; + ServiceToast.show( + context, + 'All notifications marked as read', + ); + } on Object catch (_) { + if (!context.mounted) return; + ServiceToast.show( + context, + 'Failed to mark notifications as read', + isError: true, + ); + } }, child: const Text('Mark all read'), ), @@ -148,7 +167,36 @@ class _NotificationsScreenState extends ConsumerState Expanded( child: notificationsAsync.when( data: (data) => ServiceRefreshIndicator( - onRefresh: () => ref.refresh(notificationsProvider.future), + onRefresh: () async { + try { + await runUnifiedPullToRefresh( + logLabel: 'NotificationsScreen', + refreshProfile: () => ref + .read(authProvider.notifier) + .refreshProfile(force: true), + syncCron: () async { + final supabaseToken = ref + .read(supabaseClientProvider) + .auth + .currentSession + ?.accessToken; + if (supabaseToken == null) return; + await ref + .read(apiServiceProvider) + .triggerSync(supabaseToken, force: true); + }, + refreshData: () async { + final _ = await ref.refresh( + notificationsProvider.future, + ); + }, + ); + } on Object { + if (!context.mounted) rethrow; + ServiceToast.show(context, 'Refresh failed', isError: true); + rethrow; + } + }, child: _buildList(context, ref, data), ), loading: () => const Center(child: CircularProgressIndicator()), @@ -308,63 +356,68 @@ class _PermissionBanner extends StatelessWidget { @override Widget build(BuildContext context) { - return GestureDetector( - onTap: openAppSettings, - child: Container( - margin: const EdgeInsets.fromLTRB(16, 12, 16, 0), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), - decoration: BoxDecoration( - color: Colors.amber.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: Colors.amber.withValues(alpha: 0.25), + return Semantics( + button: true, + label: 'Open app settings', + child: InkWell( + onTap: openAppSettings, + borderRadius: BorderRadius.circular(16), + child: Container( + margin: const EdgeInsets.fromLTRB(16, 12, 16, 0), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: Colors.amber.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: Colors.amber.withValues(alpha: 0.25), + ), ), - ), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.amber.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(10), - ), - child: const Icon( - LucideIcons.bellOff, - size: 18, - color: Colors.amber, + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.amber.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + LucideIcons.bellOff, + size: 18, + color: Colors.amber, + ), ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Notifications are paused', - style: GoogleFonts.manrope( - fontSize: 13, - fontWeight: FontWeight.w800, - color: Colors.amber.shade700, + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Notifications are paused', + style: GoogleFonts.manrope( + fontSize: 13, + fontWeight: FontWeight.w800, + color: Colors.amber.shade700, + ), ), - ), - const SizedBox(height: 2), - Text( - 'Tap to enable push notifications in Settings.', - style: GoogleFonts.manrope( - fontSize: 11, - fontWeight: FontWeight.w500, - color: Colors.amber.shade600, + const SizedBox(height: 2), + Text( + 'Tap to enable push notifications in Settings.', + style: GoogleFonts.manrope( + fontSize: 11, + fontWeight: FontWeight.w500, + color: Colors.amber.shade600, + ), ), - ), - ], + ], + ), ), - ), - Icon( - LucideIcons.arrowRight, - size: 16, - color: Colors.amber.withValues(alpha: 0.6), - ), - ], + Icon( + LucideIcons.arrowRight, + size: 16, + color: Colors.amber.withValues(alpha: 0.6), + ), + ], + ), ), ), ).animate().fadeIn(duration: 300.ms).slideY(begin: -0.1); diff --git a/mobile/lib/screens/profile_dump_screen.dart b/mobile/lib/screens/profile_dump_screen.dart index ea033d59..22923587 100644 --- a/mobile/lib/screens/profile_dump_screen.dart +++ b/mobile/lib/screens/profile_dump_screen.dart @@ -78,7 +78,7 @@ class _ProfileDumpContent extends ConsumerWidget { } return s; } on Object catch (e) { - AppLogger.w('ProfileDumpScreen: Failed to format date', e); + AppLogger.e('ProfileDumpScreen: Failed to format date', e); return s; } } @@ -102,7 +102,7 @@ class _ProfileDumpContent extends ConsumerWidget { .firstWhere((i) => i.id.toString().trim() == searchId) .name; } on Object catch (e) { - AppLogger.w( + AppLogger.e( 'ProfileDumpScreen: Failed to resolve institution by id', e, ); @@ -675,27 +675,30 @@ class _InfoRow { final IconData? trailingIcon; Widget _buildRow(BuildContext context) { - return GestureDetector( - onTap: - onTap ?? - (copyable - ? () { - final _ = Clipboard.setData(ClipboardData(text: value)); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - '$label copied', - style: const TextStyle(fontSize: 13), - ), - duration: const Duration(seconds: 1), - behavior: SnackBarBehavior.floating, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), + final effectiveOnTap = + onTap ?? + (copyable + ? () { + final _ = Clipboard.setData(ClipboardData(text: value)); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '$label copied', + style: const TextStyle(fontSize: 13), ), - ); - } - : null), + duration: const Duration(seconds: 1), + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + ); + } + : null); + + return InkWell( + onTap: effectiveOnTap, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 11), child: Row( diff --git a/mobile/lib/screens/profile_screen.dart b/mobile/lib/screens/profile_screen.dart index 5058972c..e54f9bd8 100644 --- a/mobile/lib/screens/profile_screen.dart +++ b/mobile/lib/screens/profile_screen.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:ghostclass/logic/attendance_utils.dart' as utils; import 'package:ghostclass/providers/auth_provider.dart'; import 'package:ghostclass/services/api_service.dart'; import 'package:ghostclass/services/logger.dart'; @@ -53,7 +54,7 @@ class _ProfileScreenState extends ConsumerState try { _selectedBirthDate = DateTime.parse(user!.profile!.birthDate!); } on Object catch (e) { - AppLogger.w('ProfileScreen: Failed to parse birth date', e); + AppLogger.e('ProfileScreen: Failed to parse birth date', e); } } } @@ -77,9 +78,17 @@ class _ProfileScreenState extends ConsumerState Supabase.instance.client.auth.currentSession?.accessToken; if (supabaseToken == null) throw Exception('Session expired'); + final normalizedFirstName = utils.normalizePersonName( + _firstNameController.text, + ); + final rawLastName = _lastNameController.text.trim(); + final normalizedLastName = rawLastName.isEmpty + ? null + : utils.normalizePersonName(rawLastName); + final data = { - 'first_name': _firstNameController.text.trim(), - 'last_name': _lastNameController.text.trim(), + 'first_name': normalizedFirstName, + 'last_name': normalizedLastName, 'gender': _selectedGender, 'birth_date': _selectedBirthDate != null ? DateFormat('yyyy-MM-dd').format(_selectedBirthDate!) @@ -262,10 +271,15 @@ class _ProfileScreenState extends ConsumerState label: 'First Name', controller: _firstNameController, enabled: _isEditing, - maxLength: 50, + maxLength: 100, + textCapitalization: TextCapitalization.words, validator: (v) { - if ((v?.trim().length ?? 0) < 2) return 'Min 2 characters'; - if ((v?.trim().length ?? 0) > 50) return 'Max 50 characters'; + final value = v?.trim() ?? ''; + if (value.length < 2) return 'Min 2 characters'; + if (value.length > 100) return 'Max 100 characters'; + if (!utils.isValidPersonName(value)) { + return 'Name contains invalid characters'; + } return null; }, ), @@ -274,9 +288,15 @@ class _ProfileScreenState extends ConsumerState label: 'Last Name', controller: _lastNameController, enabled: _isEditing, - maxLength: 50, + maxLength: 100, + textCapitalization: TextCapitalization.words, validator: (v) { - if ((v?.trim().length ?? 0) > 50) return 'Max 50 characters'; + final value = v?.trim() ?? ''; + if (value.isEmpty) return null; + if (value.length > 100) return 'Max 100 characters'; + if (!utils.isValidPersonName(value)) { + return 'Name contains invalid characters'; + } return null; }, ), @@ -687,7 +707,7 @@ class _ProfileScreenState extends ConsumerState ).format(DateTime.parse(user.profile!.ezygoCreatedAt!)); } } on Object catch (e) { - AppLogger.w( + AppLogger.e( 'ProfileScreen: Failed to parse EzyGo created date', e, ); @@ -699,7 +719,7 @@ class _ProfileScreenState extends ConsumerState ).format(DateTime.parse(user.profile!.createdAt!)); } } on Object catch (e) { - AppLogger.w( + AppLogger.e( 'ProfileScreen: Failed to parse account created date', e, ); diff --git a/mobile/lib/screens/scores_screen.dart b/mobile/lib/screens/scores_screen.dart index 8909d522..86928b65 100644 --- a/mobile/lib/screens/scores_screen.dart +++ b/mobile/lib/screens/scores_screen.dart @@ -2,11 +2,15 @@ import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:ghostclass/models/score.dart'; +import 'package:ghostclass/providers/auth_provider.dart'; import 'package:ghostclass/providers/score_provider.dart'; import 'package:ghostclass/providers/ui_state_provider.dart'; +import 'package:ghostclass/services/api_service.dart'; +import 'package:ghostclass/services/refresh_coordinator.dart'; import 'package:ghostclass/theme/app_theme.dart'; import 'package:ghostclass/widgets/loading_overlay.dart'; import 'package:ghostclass/widgets/service_refresh_indicator.dart'; +import 'package:ghostclass/widgets/service_toast.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:intl/intl.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; @@ -71,8 +75,30 @@ class _ScoresScreenState extends ConsumerState { ServiceRefreshIndicator( useOverlay: false, onRefresh: () async { - final scoreNotifier = ref.read(scoreProvider.notifier); - await scoreNotifier.refresh(); + try { + await runUnifiedPullToRefresh( + logLabel: 'ScoresScreen', + refreshProfile: () => ref + .read(authProvider.notifier) + .refreshProfile(force: true), + syncCron: () async { + final supabaseToken = ref + .read(supabaseClientProvider) + .auth + .currentSession + ?.accessToken; + if (supabaseToken == null) return; + await ref + .read(apiServiceProvider) + .triggerSync(supabaseToken, force: true); + }, + refreshData: () => ref.read(scoreProvider.notifier).refresh(), + ); + } on Object { + if (!context.mounted) rethrow; + ServiceToast.show(context, 'Refresh failed', isError: true); + rethrow; + } }, child: CustomScrollView( physics: const BouncingScrollPhysics( diff --git a/mobile/lib/screens/splash_screen.dart b/mobile/lib/screens/splash_screen.dart index 3b06ee87..95966783 100644 --- a/mobile/lib/screens/splash_screen.dart +++ b/mobile/lib/screens/splash_screen.dart @@ -12,6 +12,11 @@ import 'package:ghostclass/logic/security_utils.dart'; import 'package:ghostclass/logic/support_helper.dart'; import 'package:ghostclass/providers/app_update_provider.dart'; import 'package:ghostclass/providers/auth_provider.dart'; +import 'package:ghostclass/providers/dashboard_provider.dart'; +import 'package:ghostclass/providers/leave_provider.dart'; +import 'package:ghostclass/providers/notification_provider.dart'; +import 'package:ghostclass/providers/score_provider.dart'; +import 'package:ghostclass/providers/tracking_provider.dart'; import 'package:ghostclass/services/api_service.dart'; import 'package:ghostclass/services/jwe_service.dart'; import 'package:ghostclass/services/logger.dart'; @@ -28,6 +33,23 @@ class SplashScreen extends ConsumerStatefulWidget { } class _SplashScreenState extends ConsumerState { + void _prewarmAppData() { + void prewarm(Future future, String label) { + AppLogger.safeUnawait( + future.catchError((Object e, StackTrace st) { + AppLogger.e('SplashScreen: $label prewarm failed', e, st); + }), + 'SplashScreen: $label prewarm', + ); + } + + prewarm(ref.read(dashboardProvider.future), 'dashboard'); + prewarm(ref.read(trackingProvider.future), 'tracking'); + prewarm(ref.read(leaveProvider.future), 'leave'); + prewarm(ref.read(scoreProvider.future), 'scores'); + prewarm(ref.read(notificationsProvider.future), 'notifications'); + } + @override void initState() { super.initState(); @@ -40,6 +62,12 @@ class _SplashScreenState extends ConsumerState { // 1. Proactively pre-warm security layers while logo is showing final jwePreWarm = JweService.instance.preWarm(); final apiPreWarm = ref.read(apiServiceProvider).preWarm(); + final splashHold = Future.delayed( + const Duration(milliseconds: 3000), + () { + AppLogger.i('SplashScreen: 3s delay completed'); + }, + ); // 2. Critical Security Check First try { @@ -71,9 +99,6 @@ class _SplashScreenState extends ConsumerState { apiPreWarm.then((_) { AppLogger.i('SplashScreen: apiPreWarm completed'); }), - Future.delayed(const Duration(milliseconds: 3000)).then((_) { - AppLogger.i('SplashScreen: 3s delay completed'); - }), ]); AppLogger.i('SplashScreen: Future.wait completed successfully'); @@ -84,7 +109,7 @@ class _SplashScreenState extends ConsumerState { ref.read(appUpdateProvider.notifier).setCheckResult(versionResult); if (versionResult.isForceUpdate) { - AppLogger.w('SplashScreen: Force update required!'); + AppLogger.e('SplashScreen: Force update required!'); if (!mounted) return; await AppUpdateDialog.show( context, @@ -97,6 +122,10 @@ class _SplashScreenState extends ConsumerState { } final user = results[1] as AuthenticatedUser?; + _prewarmAppData(); + + await splashHold; + AppLogger.i( 'SplashScreen: Initialized user: ${user?.supabaseUserId ?? "null"} (syncing: ${user?.isSyncing ?? "false"})', ); @@ -127,16 +156,20 @@ class _SplashScreenState extends ConsumerState { final reason = e.details?['reason'] ?? e.message; final action = e.details?['action'] ?? 'Please restart the app.'; final criticalRisk = e.details?['criticalRisk'] == true; + final appCheckError = e.details?['appCheckError'] as String?; + + AppLogger.e( + 'SplashScreen: Security failure detected. Critical: $criticalRisk, AppCheckError: $appCheckError', + e, + ); if (criticalRisk) { - ref.read(apiServiceProvider).clearCaches(); + api.clearCaches(); await ref.read(authProvider.notifier).logout(force: true); } if (!mounted) return; - final appCheckError = e.details?['appCheckError']; - // Use backend-provided strings directly for the main message final dialogMessage = '$reason\n\n$action'; @@ -150,8 +183,12 @@ class _SplashScreenState extends ConsumerState { '$e\n\n' '${appCheckError != null ? "Local Error: $appCheckError" : ""}', ), - retryLabel: criticalRisk ? 'Close App' : 'Restart App', - onRetry: () => exit(0), + retryLabel: Platform.isAndroid + ? (criticalRisk ? 'Close App' : 'Restart App') + : (criticalRisk ? null : 'Retry'), + onRetry: Platform.isAndroid + ? () => exit(0) + : (criticalRisk ? null : _initializeApp), ); return; } diff --git a/mobile/lib/screens/tracking_screen.dart b/mobile/lib/screens/tracking_screen.dart index 673670f0..ce5f90ab 100644 --- a/mobile/lib/screens/tracking_screen.dart +++ b/mobile/lib/screens/tracking_screen.dart @@ -1,4 +1,3 @@ -import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -13,6 +12,7 @@ import 'package:ghostclass/providers/tracking_provider.dart'; import 'package:ghostclass/providers/tracking_ui_provider.dart'; import 'package:ghostclass/services/api_service.dart'; import 'package:ghostclass/services/logger.dart'; +import 'package:ghostclass/services/refresh_coordinator.dart'; import 'package:ghostclass/theme/app_theme.dart'; import 'package:ghostclass/widgets/loading_overlay.dart'; import 'package:ghostclass/widgets/service_error_view.dart'; @@ -116,10 +116,27 @@ class _TrackingScreenState extends ConsumerState return ServiceRefreshIndicator( onRefresh: () async { try { - final trackingNotifier = ref.read(trackingProvider.notifier); - await trackingNotifier.refresh(forceSync: true); + await runUnifiedPullToRefresh( + logLabel: 'TrackingScreen', + refreshProfile: () => + ref.read(authProvider.notifier).refreshProfile(force: true), + syncCron: () async { + final supabaseToken = ref + .read(supabaseClientProvider) + .auth + .currentSession + ?.accessToken; + if (supabaseToken == null) return; + await ref + .read(apiServiceProvider) + .triggerSync(supabaseToken, force: true); + }, + refreshData: () => ref.read(trackingProvider.notifier).refresh(), + ); } on Object catch (e, st) { AppLogger.e('TrackingScreen: Pull-to-refresh failed', e, st); + if (!mounted) rethrow; + ServiceToast.show(context, 'Refresh failed', isError: true); await handleError(e, title: 'Refresh Failed'); } }, @@ -307,7 +324,7 @@ class _TrackingScreenState extends ConsumerState List? allCourses, List keys, ) { - unawaited( + AppLogger.safeUnawait( showModalBottomSheet( context: context, backgroundColor: Theme.of(context).scaffoldBackgroundColor, @@ -326,7 +343,11 @@ class _TrackingScreenState extends ConsumerState Navigator.pop(context); }, ), + ).catchError( + (Object e, StackTrace st) => + AppLogger.e('TrackingScreen: showModalBottomSheet failed', e, st), ), + 'TrackingScreen: subject picker', ); } @@ -358,7 +379,7 @@ class _TrackingScreenState extends ConsumerState final title = isFiltered ? 'Clear Subject Records' : 'Delete All Records'; final buttonText = isFiltered ? 'CLEAR SUBJECT' : 'DELETE ALL'; - unawaited( + AppLogger.safeUnawait( showDialog( context: context, builder: (context) { @@ -474,12 +495,19 @@ class _TrackingScreenState extends ConsumerState ), ); }, + ).catchError( + (Object e, StackTrace st) => AppLogger.e( + 'TrackingScreen: showDeleteAllConfirm dialog failed', + e, + st, + ), ), + 'TrackingScreen: delete all confirm', ); } void _showDeleteRecordConfirm(int id) { - unawaited( + AppLogger.safeUnawait( showDialog( context: context, builder: (context) { @@ -593,7 +621,14 @@ class _TrackingScreenState extends ConsumerState ), ); }, + ).catchError( + (Object e, StackTrace st) => AppLogger.e( + 'TrackingScreen: showDeleteRecordConfirm dialog failed', + e, + st, + ), ), + 'TrackingScreen: delete record confirm', ); } } diff --git a/mobile/lib/services/api_service.dart b/mobile/lib/services/api_service.dart index 7482487d..73f6774a 100644 --- a/mobile/lib/services/api_service.dart +++ b/mobile/lib/services/api_service.dart @@ -61,7 +61,11 @@ class ApiService { String supabaseToken, { bool sync = false, bool force = false, - }) => _auth.refreshProfile(supabaseToken, sync: sync, force: force); + }) => _auth.refreshProfile( + supabaseToken, + sync: sync, + force: force, + ); Future> syncMobileAuth(String supabaseToken) => _auth.syncMobileAuth(supabaseToken); @@ -179,12 +183,8 @@ class ApiService { _lastSyncTime = DateTime.now(); return response; } on Object catch (e) { - AppLogger.w('ApiService: Background sync failed', e); - // Return a mock 304 to let downstream continue without hanging - return Response( - requestOptions: RequestOptions(path: 'sync'), - statusCode: 304, - ); + AppLogger.e('ApiService: Background sync failed', e); + rethrow; } finally { _syncInFlight = null; } @@ -193,6 +193,20 @@ class ApiService { return _syncInFlight!; } + /// Schedule a background sync safely. This wraps [triggerSync] and ensures + /// any thrown errors are logged and do not bubble to callers that intended + /// to run sync in a fire-and-forget manner. + Future scheduleSync( + String supabaseToken, { + bool force = false, + }) async { + try { + await triggerSync(supabaseToken, force: force); + } on Object catch (e, st) { + AppLogger.e('ApiService: Background scheduled sync failed', e, st); + } + } + Future> addCourse({ required String courseCode, required String courseName, @@ -212,6 +226,25 @@ class ApiService { ); } + Future> upsertInstructor({ + required String courseCode, + required String instructorName, + required String semester, + required String academicYear, + required String supabaseToken, + }) async { + return client.post( + '${AppConfig.ghostclassApiUrl}/instructors/upsert', + data: { + 'courseCode': courseCode, + 'instructorName': instructorName, + 'semester': semester, + 'academicYear': academicYear, + }, + options: Options(headers: {'Authorization': 'Bearer $supabaseToken'}), + ); + } + // --- Error Handling --- AppException mapDioError(DioException e) { final status = e.response?.statusCode; diff --git a/mobile/lib/services/auth_service.dart b/mobile/lib/services/auth_service.dart index 0a236728..7d0642e9 100644 --- a/mobile/lib/services/auth_service.dart +++ b/mobile/lib/services/auth_service.dart @@ -41,7 +41,7 @@ class AuthService { _ezygoAuthUrl, data: { 'username': username.trim(), - 'password': password.trim(), + 'password': password, 'stay_logged_in': true, }, options: Options(validateStatus: (s) => s != null && s < 600), @@ -144,7 +144,7 @@ class AuthService { } Future> getUser(SecureStorageService storage) async { - final token = await storage.getEzygoToken(); + final token = await storage.getNormalizedEzygoToken(); final path = '${AppConfig.ezygoApiRoot}/user'; if (token == null) return _dio.get(path); return _dio.get( diff --git a/mobile/lib/services/cache_manager.dart b/mobile/lib/services/cache_manager.dart new file mode 100644 index 00000000..0bdb87ef --- /dev/null +++ b/mobile/lib/services/cache_manager.dart @@ -0,0 +1,27 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:ghostclass/services/api_service.dart'; +import 'package:ghostclass/services/logger.dart'; +import 'package:ghostclass/services/secure_storage.dart'; + +/// Centralises cache invalidation for the app so we have a single +/// intentional place to clear caches on logout, academic changes, and errors. +class CacheManager { + CacheManager(this._ref); + final Ref _ref; + + Future clearAllCaches() async { + try { + // Clear in-memory/HTTP caches first + _ref.read(apiServiceProvider).clearCaches(); + + // Then wipe secure storage (tokens/profile/settings) + await _ref.read(secureStorageProvider).clearAll(); + } on Object catch (e) { + AppLogger.e('CacheManager: Failed to clear caches', e); + } + } +} + +final cacheManagerProvider = Provider((ref) { + return CacheManager(ref); +}); diff --git a/mobile/lib/services/dio_service.dart b/mobile/lib/services/dio_service.dart index 9918a6c0..29404c16 100644 --- a/mobile/lib/services/dio_service.dart +++ b/mobile/lib/services/dio_service.dart @@ -27,17 +27,7 @@ class DioService { ), ); - securityDio = Dio( - BaseOptions( - baseUrl: AppConfig.ghostclassApiUrl, - connectTimeout: timeout, - receiveTimeout: timeout, - sendTimeout: timeout, - ), - ); - dio.addSentry(); - securityDio.addSentry(); // Attach JWE Layer first dio.interceptors.add(_ref.read(jweInterceptorProvider)); @@ -66,8 +56,32 @@ class DioService { return handler.next(response); }, onError: (err, handler) { - if (err.response?.statusCode == 401) { - _handle401(err.requestOptions); + try { + if (err.response?.statusCode == 401) { + _handle401(err.requestOptions); + } + + // If App Check produced a local error (token issue), surface a + // lockdown event so security flows can react (e.g. forced logout). + final appCheckError = + err.requestOptions.extra['appCheckError'] as String?; + if (err.response?.statusCode == 403 && appCheckError != null) { + AppLogger.e( + 'DioService: 403 + App Check error detected. Triggering security lockdown.', + Exception(appCheckError), + ); + try { + _lockdownController.add({ + 'title': 'Device verification failed', + 'reason': appCheckError, + 'technicalDetails': appCheckError, + }); + } on Object catch (e, st) { + AppLogger.e('DioService: Failed to emit lockdown event', e, st); + } + } + } on Object catch (e, st) { + AppLogger.e('DioService: onError handler failed', e, st); } return handler.next(err); }, @@ -77,7 +91,6 @@ class DioService { final Ref _ref; late final Dio dio; - late final Dio securityDio; FirebaseAppCheck get _appCheck => _ref.read(appCheckProvider); @@ -107,6 +120,7 @@ class DioService { } _last401Broadcast = now; + AppLogger.d('DioService: Emitting 401 via onUnauthorized stream'); _unauthorizedController.add(null); } @@ -117,31 +131,74 @@ class DioService { // Deduplicate parallel token requests to prevent "Too many attempts" String? appCheckToken; if (useLimited) { - _limitedTokenFetchInFlight ??= _appCheck.getLimitedUseToken(); + var isNew = false; + if (_limitedTokenFetchInFlight == null) { + _limitedTokenFetchInFlight = _appCheck.getLimitedUseToken(); + isNew = true; + } appCheckToken = await _limitedTokenFetchInFlight!.timeout( const Duration(seconds: 10), ); - _limitedTokenFetchInFlight = null; // Clear after completion + if (isNew) { + AppLogger.safeUnawait( + Future.delayed(const Duration(seconds: 5), () { + _limitedTokenFetchInFlight = null; + }).catchError( + (Object e, StackTrace st) { + AppLogger.e( + 'DioService: delayed clear of limited token fetch failed', + e, + st, + ); + }, + ), + 'DioService: delayed clear limited token fetch', + ); + } } else { - _tokenFetchInFlight ??= _appCheck.getToken(); + var isNew = false; + if (_tokenFetchInFlight == null) { + _tokenFetchInFlight = _appCheck.getToken(); + isNew = true; + } appCheckToken = await _tokenFetchInFlight!.timeout( const Duration(seconds: 10), ); - _tokenFetchInFlight = null; // Clear after completion + if (isNew) { + AppLogger.safeUnawait( + Future.delayed(const Duration(seconds: 5), () { + _tokenFetchInFlight = null; + }).catchError( + (Object e, StackTrace st) { + AppLogger.e( + 'DioService: delayed clear of token fetch failed', + e, + st, + ); + }, + ), + 'DioService: delayed clear token fetch', + ); + } } if (appCheckToken != null && appCheckToken.isNotEmpty) { options.headers['X-Firebase-AppCheck'] = appCheckToken; + AppLogger.d( + 'DioService: App Check token attached (limited: $useLimited, length: ${appCheckToken.length})', + ); } else { - options.extra['appCheckError'] = + const errorMsg = 'App Check token is empty - verify Firebase activation'; + options.extra['appCheckError'] = errorMsg; + AppLogger.e('DioService: $errorMsg'); } } on Object catch (e) { // Ensure futures are cleared on error to allow retries _tokenFetchInFlight = null; _limitedTokenFetchInFlight = null; - AppLogger.w('DioService: Security headers failed: $e'); + AppLogger.e('DioService: Security headers failed: $e'); options.extra['appCheckError'] = e.toString(); } } @@ -150,7 +207,6 @@ class DioService { await _unauthorizedController.close(); await _lockdownController.close(); dio.close(); - securityDio.close(); } } diff --git a/mobile/lib/services/ezygo_service.dart b/mobile/lib/services/ezygo_service.dart index c86c308a..451b6310 100644 --- a/mobile/lib/services/ezygo_service.dart +++ b/mobile/lib/services/ezygo_service.dart @@ -28,7 +28,7 @@ class EzygoService { void clearCaches() => _fetcher.clearAll(); Future> fetchCourses(SecureStorageService storage) async { - final token = await storage.getEzygoToken(); + final token = await storage.getNormalizedEzygoToken(); final path = '$_ezygoApiRoot/institutionuser/courses/withusers'; if (token == null) return _ref.read(dioServiceProvider).dio.get(path); return _fetcher.fetch(path: path, token: token); @@ -37,7 +37,7 @@ class EzygoService { Future> fetchAttendanceReportDetailed( SecureStorageService storage, ) async { - final token = await storage.getEzygoToken(); + final token = await storage.getNormalizedEzygoToken(); final path = '$_ezygoApiRoot/attendancereports/student/detailed'; if (token == null) { return _ref @@ -59,7 +59,7 @@ class EzygoService { Future> getInstitutions( SecureStorageService storage, ) async { - final token = await storage.getEzygoToken(); + final token = await storage.getNormalizedEzygoToken(); final path = '$_ezygoApiRoot/institutionusers/myinstitutions'; if (token == null) return _ref.read(dioServiceProvider).dio.get(path); return _fetcher.fetch(path: path, token: token); @@ -69,7 +69,7 @@ class EzygoService { int institutionUserId, SecureStorageService storage, ) async { - final token = await storage.getEzygoToken(); + final token = await storage.getNormalizedEzygoToken(); return _ref .read(dioServiceProvider) .dio @@ -87,7 +87,7 @@ class EzygoService { String semester, SecureStorageService storage, ) async { - final token = await storage.getEzygoToken(); + final token = await storage.getNormalizedEzygoToken(); return _ref .read(dioServiceProvider) .dio @@ -105,7 +105,7 @@ class EzygoService { String year, SecureStorageService storage, ) async { - final token = await storage.getEzygoToken(); + final token = await storage.getNormalizedEzygoToken(); return _ref .read(dioServiceProvider) .dio @@ -120,7 +120,7 @@ class EzygoService { } Future> fetchSemester(SecureStorageService storage) async { - final token = await storage.getEzygoToken(); + final token = await storage.getNormalizedEzygoToken(); final path = '$_ezygoApiRoot/user/setting/default_semester'; if (token == null) return _ref.read(dioServiceProvider).dio.get(path); return _fetcher.fetch(path: path, token: token); @@ -129,14 +129,46 @@ class EzygoService { Future> fetchAcademicYear( SecureStorageService storage, ) async { - final token = await storage.getEzygoToken(); + final token = await storage.getNormalizedEzygoToken(); final path = '$_ezygoApiRoot/user/setting/default_academic_year'; if (token == null) return _ref.read(dioServiceProvider).dio.get(path); return _fetcher.fetch(path: path, token: token); } + Future _fetchWithCache( + String path, + String token, + SecureStorageService storage, { + Duration ttl = const Duration(days: 7), + }) async { + final cacheKey = 'ezygo_static_${path.hashCode}'; + try { + final cached = await storage.getCachedData(cacheKey); + if (cached != null) { + return cached; + } + } on Object catch (e) { + AppLogger.w('EzygoService: Cache read error for $path: $e'); + } + + try { + final res = await _fetcher.fetch(path: path, token: token); + if (res.statusCode == 200) { + await storage.saveCachedData(cacheKey, res.data, ttl: ttl); + return res.data; + } else { + throw Exception('Status code: ${res.statusCode}'); + } + } on Object catch (e) { + AppLogger.w( + 'EzygoService: Network fetch failed for $path: $e. No stale-cache fallback is allowed.', + ); + rethrow; + } + } + Future> fetchLeaveData(SecureStorageService storage) async { - final token = await storage.getEzygoToken(); + final token = await storage.getNormalizedEzygoToken(); if (token == null) { throw const AppException( message: 'No EzyGo credentials found. Please log in.', @@ -144,31 +176,28 @@ class EzygoService { ); } - // 7-way concurrent fetch to match monolithic parity final results = await Future.wait([ _fetcher.fetch(path: '$_ezygoApiRoot/studentleaves', token: token), - _fetcher.fetch(path: '$_ezygoApiRoot/usersubgroups', token: token), - _fetcher.fetch(path: '$_ezygoApiRoot/attendancetypes', token: token), - _fetcher.fetch(path: '$_ezygoApiRoot/sessions', token: token), - _fetcher.fetch(path: '$_ezygoApiRoot/events', token: token), - _fetcher.fetch( - path: '$_ezygoApiRoot/institution/setting/mandatory_event_coordinator', - token: token, + _fetchWithCache('$_ezygoApiRoot/usersubgroups', token, storage), + _fetchWithCache('$_ezygoApiRoot/attendancetypes', token, storage), + _fetchWithCache('$_ezygoApiRoot/sessions', token, storage), + _fetchWithCache('$_ezygoApiRoot/events', token, storage), + _fetchWithCache( + '$_ezygoApiRoot/institution/setting/mandatory_event_coordinator', + token, + storage, ), - _fetcher.fetch( - path: '$_ezygoApiRoot/institution/setting/student_leave_approval_level', - token: token, + _fetchWithCache( + '$_ezygoApiRoot/institution/setting/student_leave_approval_level', + token, + storage, ), ]); - // If any sub-request failed, abort the entire fetch to guarantee data integrity. - final failedRequests = results.where((r) => r.statusCode != 200); - if (failedRequests.isNotEmpty) { - final summary = failedRequests - .map((r) => '${r.requestOptions.path} -> ${r.statusCode}') - .join(', '); + final leavesRes = results[0] as Response; + if (leavesRes.statusCode != 200) { AppLogger.e( - 'EzygoService.fetchLeaveData: Partial failure — [$summary]. Aborting.', + 'EzygoService.fetchLeaveData: Leaves request failed with ${leavesRes.statusCode}', ); throw const AppException( message: 'Failed to fetch complete leave data. Please try again.', @@ -176,26 +205,25 @@ class EzygoService { ); } - // Construct a merged response data map matching the original structure final mergedData = { - 'studentLeaves': results[0].data, - 'userSubgroups': results[1].data, - 'attendanceTypes': results[2].data, - 'sessions': results[3].data, - 'events': results[4].data, - 'mandatoryEventCoordinator': results[5].data, - 'leaveApprovalLevel': results[6].data, + 'studentLeaves': leavesRes.data, + 'userSubgroups': results[1], + 'attendanceTypes': results[2], + 'sessions': results[3], + 'events': results[4], + 'mandatoryEventCoordinator': results[5], + 'leaveApprovalLevel': results[6], }; return Response( - requestOptions: results[0].requestOptions, + requestOptions: leavesRes.requestOptions, data: mergedData, statusCode: 200, ); } Future> fetchExams(SecureStorageService storage) async { - final token = await storage.getEzygoToken(); + final token = await storage.getNormalizedEzygoToken(); final path = '$_ezygoApiRoot/exams'; if (token == null) return _ref.read(dioServiceProvider).dio.get(path); return _fetcher.fetch(path: path, token: token); @@ -205,7 +233,7 @@ class EzygoService { int examId, SecureStorageService storage, ) async { - final token = await storage.getEzygoToken(); + final token = await storage.getNormalizedEzygoToken(); final path = '$_ezygoApiRoot/exams/$examId/examquestions?from_view_score=true'; if (token == null) return _ref.read(dioServiceProvider).dio.get(path); @@ -216,7 +244,7 @@ class EzygoService { int examId, SecureStorageService storage, ) async { - final token = await storage.getEzygoToken(); + final token = await storage.getNormalizedEzygoToken(); final path = '$_ezygoApiRoot/exams/$examId/institutionuser/examanswers'; if (token == null) return _ref.read(dioServiceProvider).dio.get(path); return _fetcher.fetch(path: path, token: token); diff --git a/mobile/lib/services/jwe_interceptor.dart b/mobile/lib/services/jwe_interceptor.dart index c1722081..6666e2a2 100644 --- a/mobile/lib/services/jwe_interceptor.dart +++ b/mobile/lib/services/jwe_interceptor.dart @@ -24,6 +24,18 @@ class JweInterceptor extends Interceptor { _rcekMap.removeWhere( (key, entry) => now.difference(entry.timestamp).inSeconds > 120, ); + // Defensive cap: if an attacker floods requests, prevent the map from + // growing without bound by removing the oldest entries when exceeding + // a reasonable limit. + const maxEntries = 256; + if (_rcekMap.length > maxEntries) { + final entries = _rcekMap.entries.toList() + ..sort((a, b) => a.value.timestamp.compareTo(b.value.timestamp)); + final toRemove = _rcekMap.length - maxEntries; + for (var i = 0; i < toRemove; i++) { + _rcekMap.remove(entries[i].key); + } + } } @override @@ -65,7 +77,7 @@ class JweInterceptor extends Interceptor { // The server needs the RCEK to decrypt the request and to encrypt the response // In the GhostClass protocol, we send the RCEK encrypted with the server's public key final keyResult = await jweService.encryptHeaderKey(); - options.headers['X-JWE-Key'] = keyResult.jwe; + options.headers['x-jwe-key'] = keyResult.jwe; AppLogger.d('JweInterceptor: Request encrypted for ${options.path}'); } on Object catch (e) { @@ -118,9 +130,18 @@ class JweInterceptor extends Interceptor { final isEncrypted = contentType.contains('application/jose') || response.headers.value('x-jwe') == 'true'; - final requestId = - response.requestOptions.headers['X-GhostClass-Request-ID'] as String?; - final entry = _rcekMap.remove(requestId); + // Header casing can vary depending on platform/transport. Try common + // variants to robustly retrieve the request ID used to store the RCEK. + String? requestId; + final headers = response.requestOptions.headers; + if (headers.containsKey('X-GhostClass-Request-ID')) { + requestId = headers['X-GhostClass-Request-ID'] as String?; + } else if (headers.containsKey('x-ghostclass-request-id')) { + requestId = headers['x-ghostclass-request-id'] as String?; + } else if (headers.containsKey('X-Ghostclass-Request-Id')) { + requestId = headers['X-Ghostclass-Request-Id'] as String?; + } + final entry = requestId == null ? null : _rcekMap.remove(requestId); final rcek = entry?.rcek; if (isEncrypted && rcek != null) { @@ -158,8 +179,14 @@ class JweInterceptor extends Interceptor { @override void onError(DioException err, ErrorInterceptorHandler handler) { // Clean up RCEK on error to prevent memory leaks - final requestId = err.requestOptions.headers['X-GhostClass-Request-ID']; - _rcekMap.remove(requestId); + final headers = err.requestOptions.headers; + String? requestId; + if (headers.containsKey('X-GhostClass-Request-ID')) { + requestId = headers['X-GhostClass-Request-ID'] as String?; + } else if (headers.containsKey('x-ghostclass-request-id')) { + requestId = headers['x-ghostclass-request-id'] as String?; + } + if (requestId != null) _rcekMap.remove(requestId); return handler.next(err); } } diff --git a/mobile/lib/services/jwe_service.dart b/mobile/lib/services/jwe_service.dart index 4d9faaa4..9351bf70 100644 --- a/mobile/lib/services/jwe_service.dart +++ b/mobile/lib/services/jwe_service.dart @@ -95,11 +95,20 @@ class JweService { // Stale-While-Revalidate: if cached keys are older than 24 hours, // refresh from network in the background without blocking startup. if (DateTime.now().difference(cachedTime).inHours >= 24) { - unawaited(_refreshJwksFromNetwork(prefs)); + AppLogger.safeUnawait( + _refreshJwksFromNetwork(prefs).catchError( + (Object e, StackTrace st) => AppLogger.e( + 'JweService: Background JWKS refresh failed', + e, + st, + ), + ), + 'JweService: background JWKS refresh', + ); } return; } on Object catch (e) { - AppLogger.w('JweService: Failed to parse cached JWKS time', e); + AppLogger.e('JweService: Failed to parse cached JWKS time', e); } } @@ -165,13 +174,22 @@ class JweService { JsonWebKey _getSanitizedServerKey(JsonWebKey key) { final rawJson = key.toJson(); + // Ensure the server key has the required RSA parameters. If the server + // provides an unexpected key shape, fail fast instead of constructing a + // potentially invalid JWK which could cause subtle crypto errors later. + final n = rawJson['n'] as String?; + final e = rawJson['e'] as String?; + if (n == null || n.isEmpty || e == null || e.isEmpty) { + throw Exception('Server JWK missing RSA modulus or exponent.'); + } + return JsonWebKey.fromJson({ 'kty': 'RSA', - 'n': rawJson['n'], - 'e': rawJson['e'], + 'n': n, + 'e': e, if (rawJson['kid'] != null) 'kid': rawJson['kid'], - // Notice: We completely omit 'alg', 'use', and 'key_ops'. - // The library can no longer reject it for operation mismatches. + // Notice: We deliberately omit 'alg', 'use', and 'key_ops' to avoid + // rejecting the key for operation mismatches while preserving the kid. }); } diff --git a/mobile/lib/services/logger.dart b/mobile/lib/services/logger.dart index 63ed221a..d4afcffd 100644 --- a/mobile/lib/services/logger.dart +++ b/mobile/lib/services/logger.dart @@ -94,6 +94,21 @@ class AppLogger { } } + /// Safely attach a top-level catcher to a fire-and-forget future. + /// Use this instead of calling `unawaited(...)` directly to ensure + /// exceptions are logged and do not get silently dropped. + static void safeUnawait(Future future, [String? context]) { + unawaited( + future.catchError( + (Object e, StackTrace st) => AppLogger.e( + 'SafeUnawait${context != null ? ': $context' : ''}', + e, + st, + ), + ), + ); + } + /// Logs an information message. static void i(String message) { _addToBuffer('INFO', message); @@ -101,6 +116,10 @@ class AppLogger { unawaited( Sentry.addBreadcrumb( Breadcrumb(message: message, level: SentryLevel.info), + ).catchError( + (Object e, StackTrace st) { + debugPrint('Sentry breadcrumb failed: $e $st'); + }, ), ); } @@ -119,6 +138,10 @@ class AppLogger { level: SentryLevel.warning, data: error != null ? {'error': error.toString()} : null, ), + ).catchError( + (Object e, StackTrace st) { + debugPrint('Sentry breadcrumb failed: $e $st'); + }, ), ); } @@ -165,9 +188,11 @@ class AppLogger { uuidRegex.firstMatch(error?.toString() ?? ''); if (match != null) hashedUserId = _hashString(match.group(0)!); + final capturedError = error ?? Exception(message); + unawaited( Sentry.captureException( - error ?? message, + capturedError, stackTrace: stackTrace, withScope: (scope) async { await scope.setTag('logger_message', sanitizedMessage); @@ -189,6 +214,11 @@ class AppLogger { } await scope.setContexts('logger', loggerContext); }, + ).catchError( + (Object e, StackTrace st) { + debugPrint('Sentry capture failed: $e $st'); + return const SentryId.empty(); + }, ), ); } diff --git a/mobile/lib/services/push_notification_service.dart b/mobile/lib/services/push_notification_service.dart index 29a084d8..7ac528ab 100644 --- a/mobile/lib/services/push_notification_service.dart +++ b/mobile/lib/services/push_notification_service.dart @@ -15,6 +15,7 @@ import 'package:ghostclass/services/dio_service.dart'; import 'package:ghostclass/services/logger.dart'; import 'package:ghostclass/services/secure_storage.dart'; import 'package:ghostclass/widgets/service_toast.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; /// Top-level background message handler for FCM. /// Must be a standalone function to run in a separate isolate. @@ -40,6 +41,8 @@ class PushNotificationService { StreamSubscription? _tokenSub; StreamSubscription? _messageSub; StreamSubscription? _messageOpenedSub; + StreamSubscription? _deferredAuthSub; + Timer? _deferredAuthTimer; Dio get _dio => _ref.read(dioServiceProvider).dio; SecureStorageService get _storage => _ref.read(secureStorageProvider); @@ -72,12 +75,21 @@ class PushNotificationService { } // Configure native iOS foreground notifications - unawaited( - _messaging.setForegroundNotificationPresentationOptions( - alert: true, - badge: true, - sound: true, - ), + AppLogger.safeUnawait( + _messaging + .setForegroundNotificationPresentationOptions( + alert: true, + badge: true, + sound: true, + ) + .catchError( + (Object e, StackTrace st) => AppLogger.e( + 'PushNotificationService: Failed to set foreground presentation options', + e, + st, + ), + ), + 'PushNotificationService: set foreground presentation options', ); // Listen for active app foreground message streams @@ -88,14 +100,23 @@ class PushNotificationService { 'Received foreground message: ${message.notification?.title}', ); try { - unawaited( - AnalyticsService.instance.logCustom( - 'fcm_foreground_received', - { - 'title': message.notification?.title ?? '', - 'has_data': message.data.isNotEmpty, - }, - ), + AppLogger.safeUnawait( + AnalyticsService.instance + .logCustom( + 'fcm_foreground_received', + { + 'title': message.notification?.title ?? '', + 'has_data': message.data.isNotEmpty, + }, + ) + .catchError( + (Object e, StackTrace st) => AppLogger.e( + 'PushNotificationService: Analytics fcm_foreground_received failed', + e, + st, + ), + ), + 'PushNotificationService: analytics fcm_foreground_received', ); } on Object catch (_) {} @@ -135,14 +156,23 @@ class PushNotificationService { .listen((message) { AppLogger.i('User opened app from notification'); try { - unawaited( - AnalyticsService.instance.logCustom( - 'fcm_opened', - { - 'title': message.notification?.title ?? '', - 'has_data': message.data.isNotEmpty, - }, - ), + AppLogger.safeUnawait( + AnalyticsService.instance + .logCustom( + 'fcm_opened', + { + 'title': message.notification?.title ?? '', + 'has_data': message.data.isNotEmpty, + }, + ) + .catchError( + (Object e, StackTrace st) => AppLogger.e( + 'PushNotificationService: Analytics fcm_opened failed', + e, + st, + ), + ), + 'PushNotificationService: analytics fcm_opened', ); } on Object catch (_) {} }); @@ -152,10 +182,19 @@ class PushNotificationService { if (token != null) { await _syncTokenWithBackend(token); try { - unawaited( - AnalyticsService.instance.logCustom('fcm_token_retrieved', { - 'length': token.length, - }), + AppLogger.safeUnawait( + AnalyticsService.instance + .logCustom('fcm_token_retrieved', { + 'length': token.length, + }) + .catchError( + (Object e, StackTrace st) => AppLogger.e( + 'PushNotificationService: Analytics fcm_token_retrieved failed', + e, + st, + ), + ), + 'PushNotificationService: analytics fcm_token_retrieved', ); } on Object catch (_) {} } @@ -163,7 +202,10 @@ class PushNotificationService { // Establish ongoing refresh listener for security token rotations _tokenSub = _messaging.onTokenRefresh.listen((newToken) { AppLogger.i('FCM device token refreshed'); - unawaited(_syncTokenWithBackend(newToken)); + AppLogger.safeUnawait( + _syncTokenWithBackend(newToken), + 'FCM token refresh', + ); }); } } on Object catch (e, st) { @@ -178,14 +220,89 @@ class PushNotificationService { /// Synchronises the secure push token with the backend storage route. Future _syncTokenWithBackend(String token) async { try { - final cachedToken = await _storage.getFcmToken(); - final currentSession = _ref - .read(supabaseClientProvider) - .auth - .currentSession; - - // Avoid repeating network handshakes if token remains unchanged or session is inactive - if (cachedToken == token || currentSession == null) { + final cachedToken = await _storage.getNormalizedFcmToken(); + + if (cachedToken == token) { + return; + } + + final supabase = _ref.read(supabaseClientProvider); + final currentSession = supabase.auth.currentSession; + + // If there is no active session, schedule a one-shot listener to attempt + // registration when a session becomes available. This avoids silently + // dropping device tokens that arrived before login. + if (currentSession == null) { + await _deferredAuthSub?.cancel(); + _deferredAuthTimer?.cancel(); + + // Defensive timeout: if the user never signs in, cancel the listener + // after a reasonable period to avoid resource leaks. + _deferredAuthSub = supabase.auth.onAuthStateChange.listen((data) async { + final session = data.session; + if (session != null) { + try { + final accessToken = session.accessToken; + final baseUrl = AppConfig.ghostclassApiUrl; + final response = await _dio.post( + '$baseUrl/auth/register-fcm', + data: {'fcm_token': token.trim()}, + options: Options( + headers: {'Authorization': 'Bearer $accessToken'}, + validateStatus: (s) => s != null && s < 600, + ), + ); + if (response.statusCode == 200) { + AppLogger.i('FCM push token registered on auth event'); + try { + await _storage.saveFcmToken(token); + } on Object catch (e, st) { + AppLogger.e( + 'PushNotificationService: saveFcmToken failed on auth event', + e, + st, + ); + } + try { + AppLogger.safeUnawait( + AnalyticsService.instance + .logCustom('fcm_registered', { + 'registered': true, + }) + .catchError( + (Object e, StackTrace st) => AppLogger.e( + 'PushNotificationService: Analytics fcm_registered (deferred) failed', + e, + st, + ), + ), + 'PushNotificationService: analytics fcm_registered (deferred)', + ); + } on Object catch (_) {} + } else { + AppLogger.e( + 'FCM registration on auth event returned ${response.statusCode}', + ); + } + } on Object catch (e) { + AppLogger.e('FCM deferred registration failed', e); + } finally { + await _deferredAuthSub?.cancel(); + _deferredAuthSub = null; + _deferredAuthTimer?.cancel(); + _deferredAuthTimer = null; + } + } + }); + + // Cancel the subscription after 5 minutes if no session event occurs. + _deferredAuthTimer = Timer(const Duration(minutes: 5), () async { + AppLogger.d('FCM deferred registration listener timeout; cancelling'); + await _deferredAuthSub?.cancel(); + _deferredAuthSub = null; + _deferredAuthTimer = null; + }); + return; } @@ -203,35 +320,66 @@ class PushNotificationService { if (response.statusCode == 200) { AppLogger.i('FCM push token securely registered with backend services'); - await _storage.saveFcmToken(token); try { - unawaited( - AnalyticsService.instance.logCustom('fcm_registered', { - 'registered': true, - }), + await _storage.saveFcmToken(token); + } on Object catch (e, st) { + AppLogger.e('PushNotificationService: saveFcmToken failed', e, st); + } + try { + AppLogger.safeUnawait( + AnalyticsService.instance + .logCustom('fcm_registered', { + 'registered': true, + }) + .catchError( + (Object e, StackTrace st) => AppLogger.e( + 'PushNotificationService: Analytics fcm_registered failed', + e, + st, + ), + ), + 'PushNotificationService: analytics fcm_registered', ); } on Object catch (_) {} } else { - AppLogger.w( + AppLogger.e( 'Backend token registration returned non-success code: ${response.statusCode}', ); try { - unawaited( - AnalyticsService.instance.logCustom('fcm_registered', { - 'registered': false, - 'status': response.statusCode, - }), + AppLogger.safeUnawait( + AnalyticsService.instance + .logCustom('fcm_registered', { + 'registered': false, + 'status': response.statusCode, + }) + .catchError( + (Object e, StackTrace st) => AppLogger.e( + 'PushNotificationService: Analytics fcm_registered (failure) failed', + e, + st, + ), + ), + 'PushNotificationService: analytics fcm_registered (failure)', ); } on Object catch (_) {} } } on Object catch (e) { - AppLogger.w('FCM backend token attestation/handshake failure', e); + AppLogger.e('FCM backend token attestation/handshake failure', e); try { - unawaited( - AnalyticsService.instance.logCustom('fcm_registered', { - 'registered': false, - 'error': e.toString(), - }), + AppLogger.safeUnawait( + AnalyticsService.instance + .logCustom('fcm_registered', { + 'registered': false, + 'error': e.toString(), + }) + .catchError( + (Object e, StackTrace st) => AppLogger.e( + 'PushNotificationService: Analytics fcm_registered (exception) failed', + e, + st, + ), + ), + 'PushNotificationService: analytics fcm_registered (exception)', ); } on Object catch (_) {} } @@ -241,6 +389,8 @@ class PushNotificationService { await _tokenSub?.cancel(); await _messageSub?.cancel(); await _messageOpenedSub?.cancel(); + await _deferredAuthSub?.cancel(); + _deferredAuthTimer?.cancel(); } } diff --git a/mobile/lib/services/refresh_coordinator.dart b/mobile/lib/services/refresh_coordinator.dart new file mode 100644 index 00000000..601d6f51 --- /dev/null +++ b/mobile/lib/services/refresh_coordinator.dart @@ -0,0 +1,26 @@ +import 'package:ghostclass/services/logger.dart'; + +Future runUnifiedPullToRefresh({ + required Future Function() refreshProfile, + required Future Function() refreshData, + Future Function()? syncCron, + String logLabel = 'PullToRefresh', +}) async { + await refreshProfile(); + + final syncFuture = syncCron == null + ? Future.value() + : () async { + try { + await syncCron(); + } on Object catch (e, st) { + AppLogger.e('$logLabel: cron sync failed', e, st); + rethrow; + } + }(); + + await Future.wait([ + syncFuture, + refreshData(), + ]); +} diff --git a/mobile/lib/services/secure_storage.dart b/mobile/lib/services/secure_storage.dart index d0e70cfc..fcf8744d 100644 --- a/mobile/lib/services/secure_storage.dart +++ b/mobile/lib/services/secure_storage.dart @@ -41,49 +41,118 @@ class SecureStorageService { @visibleForTesting FlutterSecureStorage get storage => _storage; + // ─── Safe Storage Helpers ────────────────────────────────────────────────── + + Future _safeWrite({required String key, required String value}) async { + try { + await _storage.write(key: key, value: value); + } on Object catch (e, st) { + AppLogger.e('SecureStorage: Error during write of key: $key', e, st); + await _selfHeal(e); + rethrow; + } + } + + Future _safeRead({required String key}) async { + try { + return await _storage.read(key: key); + } on Object catch (e, st) { + AppLogger.e('SecureStorage: Error during read of key: $key', e, st); + await _selfHeal(e); + return null; + } + } + + Future _safeDelete({required String key}) async { + try { + await _storage.delete(key: key); + } on Object catch (e, st) { + AppLogger.e('SecureStorage: Error during delete of key: $key', e, st); + await _selfHeal(e); + rethrow; + } + } + + Future _safeDeleteAll() async { + try { + await _storage.deleteAll(); + } on Object catch (e, st) { + AppLogger.e('SecureStorage: Error during deleteAll', e, st); + // If deleteAll fails, we can't do much, but we still catch to avoid crashing + } + } + + Future _selfHeal(Object error) async { + AppLogger.e( + 'SecureStorage: Executing self-healing routine due to exception: $error', + ); + try { + await _storage.deleteAll(); + } on Object catch (e, st) { + AppLogger.e('SecureStorage: Self-healing deleteAll failed', e, st); + } + } + // ─── EzyGo Token ───────────────────────────────────────────────────────── - Future saveEzygoToken(String token) => - _storage.write(key: _Keys.ezygoToken, value: token); + Future saveEzygoToken(String token) => token.trim().isEmpty + ? _safeDelete(key: _Keys.ezygoToken) + : _safeWrite(key: _Keys.ezygoToken, value: token); - Future getEzygoToken() => _storage.read(key: _Keys.ezygoToken); + Future getEzygoToken() => _safeRead(key: _Keys.ezygoToken); + + /// Return `null` if the stored token is missing or empty to avoid callers + /// accidentally treating an empty string as a valid token. + Future getNormalizedEzygoToken() async { + final raw = await _safeRead(key: _Keys.ezygoToken); + if (raw == null) return null; + final t = raw.trim(); + return t.isEmpty ? null : t; + } - Future clearEzygoToken() => _storage.delete(key: _Keys.ezygoToken); + Future clearEzygoToken() => _safeDelete(key: _Keys.ezygoToken); // ─── FCM Token ─────────────────────────────────────────────────────────── - Future saveFcmToken(String token) => - _storage.write(key: _Keys.fcmToken, value: token); + Future saveFcmToken(String token) => token.trim().isEmpty + ? _safeDelete(key: _Keys.fcmToken) + : _safeWrite(key: _Keys.fcmToken, value: token); + + Future getFcmToken() => _safeRead(key: _Keys.fcmToken); - Future getFcmToken() => _storage.read(key: _Keys.fcmToken); + Future getNormalizedFcmToken() async { + final raw = await _safeRead(key: _Keys.fcmToken); + if (raw == null) return null; + final t = raw.trim(); + return t.isEmpty ? null : t; + } // ─── Supabase User ID ──────────────────────────────────────────────────── Future saveSupabaseUserId(String id) => - _storage.write(key: _Keys.supabaseUserId, value: id); + _safeWrite(key: _Keys.supabaseUserId, value: id); - Future getSupabaseUserId() => - _storage.read(key: _Keys.supabaseUserId); + Future getSupabaseUserId() => _safeRead(key: _Keys.supabaseUserId); // ─── EzyGo User ID & Username ──────────────────────────────────────────── Future saveEzygoUserId(String id) => - _storage.write(key: _Keys.ezygoUserId, value: id); + _safeWrite(key: _Keys.ezygoUserId, value: id); - Future getEzygoUserId() => _storage.read(key: _Keys.ezygoUserId); + Future getEzygoUserId() => _safeRead(key: _Keys.ezygoUserId); Future saveUsername(String username) => - _storage.write(key: _Keys.username, value: username); + _safeWrite(key: _Keys.username, value: username); - Future getUsername() => _storage.read(key: _Keys.username); + Future getUsername() => _safeRead(key: _Keys.username); // ─── User Profile ──────────────────────────────────────────────────────── Future saveUserProfile(UserProfile profile) => - _storage.write(key: _Keys.profile, value: jsonEncode(profile.toJson())); + _safeWrite(key: _Keys.profile, value: jsonEncode(profile.toJson())); Future getUserProfile() async { - final raw = await _storage.read(key: _Keys.profile); + final raw = await _safeRead(key: _Keys.profile); if (raw == null) return null; try { final data = jsonDecode(raw) as Map; @@ -100,11 +169,11 @@ class SecureStorageService { /// Persists user settings as a JSON blob. Future saveSettings(UserSettings settings) => - _storage.write(key: _Keys.settings, value: jsonEncode(settings.toJson())); + _safeWrite(key: _Keys.settings, value: jsonEncode(settings.toJson())); /// Returns `null` if no settings have been saved yet. Future getSettings() async { - final raw = await _storage.read(key: _Keys.settings); + final raw = await _safeRead(key: _Keys.settings); if (raw == null) return null; try { return UserSettings.fromJson(jsonDecode(raw) as Map); @@ -117,17 +186,17 @@ class SecureStorageService { // ─── Terms Acceptance ─────────────────────────────────────────────────── Future saveTermsVersion(String version) => - _storage.write(key: _Keys.termsVersion, value: version); + _safeWrite(key: _Keys.termsVersion, value: version); - Future getTermsVersion() => _storage.read(key: _Keys.termsVersion); + Future getTermsVersion() => _safeRead(key: _Keys.termsVersion); // ─── Browser Stealth Info ─────────────────────────────────────────────── Future saveStealthInfo(StealthInfo info) => - _storage.write(key: _Keys.stealthInfo, value: jsonEncode(info.toJson())); + _safeWrite(key: _Keys.stealthInfo, value: jsonEncode(info.toJson())); Future getStealthInfo() async { - final raw = await _storage.read(key: _Keys.stealthInfo); + final raw = await _safeRead(key: _Keys.stealthInfo); if (raw == null) return null; try { return StealthInfo.fromJson(jsonDecode(raw) as Map); @@ -150,36 +219,36 @@ class SecureStorageService { 'data': data, 'expiry': expiry, }; - await _storage.write(key: 'cache_$key', value: jsonEncode(payload)); + await _safeWrite(key: 'cache_$key', value: jsonEncode(payload)); } /// Returns cached data if it exists and has not expired. Future getCachedData(String key) async { - final raw = await _storage.read(key: 'cache_$key'); + final raw = await _safeRead(key: 'cache_$key'); if (raw == null) return null; try { final decoded = jsonDecode(raw) as Map; final expiry = decoded['expiry'] as int; if (DateTime.now().millisecondsSinceEpoch > expiry) { - await _storage.delete(key: 'cache_$key'); + await _safeDelete(key: 'cache_$key'); return null; } return decoded['data']; } on Object { - AppLogger.w('SecureStorage: Error decoding cache for $key'); + AppLogger.e('SecureStorage: Error decoding cache for $key'); return null; } } // ─── Academic State ─────────────────────────────────────────────────────── - Future saveAcademicState(AcademicState state) => _storage.write( + Future saveAcademicState(AcademicState state) => _safeWrite( key: _Keys.academicState, value: jsonEncode({'semester': state.semester, 'year': state.year}), ); Future getAcademicState() async { - final raw = await _storage.read(key: _Keys.academicState); + final raw = await _safeRead(key: _Keys.academicState); if (raw == null) return null; try { final decoded = jsonDecode(raw) as Map; @@ -188,7 +257,7 @@ class SecureStorageService { year: decoded['year'] as String, ); } on Object { - AppLogger.w('SecureStorage: Error decoding academic state'); + AppLogger.e('SecureStorage: Error decoding academic state'); return null; } } @@ -196,27 +265,26 @@ class SecureStorageService { // ─── Attestation Result ────────────────────────────────────────────────── Future saveAttestationResult(String resultJson) => - _storage.write(key: _Keys.attestationResult, value: resultJson); + _safeWrite(key: _Keys.attestationResult, value: resultJson); Future getAttestationResult() => - _storage.read(key: _Keys.attestationResult); + _safeRead(key: _Keys.attestationResult); // ─── Generic Read/Write (Safe access to _storage) ────────────────────── Future writeSecure(String key, String value) => - _storage.write(key: key, value: value); + _safeWrite(key: key, value: value); - Future readSecure(String key) => _storage.read(key: key); + Future readSecure(String key) => _safeRead(key: key); - Future deleteSecure(String key) => _storage.delete(key: key); + Future deleteSecure(String key) => _safeDelete(key: key); - Future deleteCachedData(String key) => - _storage.delete(key: 'cache_$key'); + Future deleteCachedData(String key) => _safeDelete(key: 'cache_$key'); // ─── Full Clear ────────────────────────────────────────────────────────── /// Deletes every key managed by this service. Should be called on logout. - Future clearAll() => _storage.deleteAll(); + Future clearAll() => _safeDeleteAll(); } final secureStorageProvider = Provider( diff --git a/mobile/lib/services/security_guard.dart b/mobile/lib/services/security_guard.dart index 7b37b4b5..b3bccb54 100644 --- a/mobile/lib/services/security_guard.dart +++ b/mobile/lib/services/security_guard.dart @@ -27,16 +27,21 @@ class SecurityGuard { /// Wipes all sensitive storage and exits the app immediately. Future wipeAndExit() async { - AppLogger.w('SecurityGuard: SECURITY BREACH DETECTED. WIPING AND EXITING.'); + AppLogger.e('SecurityGuard: SECURITY BREACH DETECTED. WIPING AND EXITING.'); try { await storage.clearAll(); if (Platform.isAndroid) { await _channel.invokeMethod('exitApp'); + } else if (Platform.isIOS) { + // On iOS, programmatic exit is not allowed. We have wiped the storage + // and will let the UI lockdown remain in place. } else { exit(0); } } on Object { - exit(1); + if (!Platform.isIOS) { + exit(1); + } } } } diff --git a/mobile/lib/services/security_service.dart b/mobile/lib/services/security_service.dart index 9443d222..46223f34 100644 --- a/mobile/lib/services/security_service.dart +++ b/mobile/lib/services/security_service.dart @@ -32,6 +32,7 @@ class SecurityService { SecurityService(this._ref); final Ref _ref; static final String _ghostclassBaseUrl = AppConfig.ghostclassApiUrl; + static const Duration _cachedAttestationMaxAge = Duration(hours: 6); Dio get _dio => _ref.read(dioServiceProvider).dio; @@ -64,28 +65,47 @@ class SecurityService { final map = jsonDecode(cachedRaw) as Map; final latestVersion = map['latestVersion'] as String; final minVersion = map['minVersion'] as String; + final cachedAt = DateTime.tryParse(map['cachedAt'] as String? ?? ''); final currentVersion = AppConfig.appVersion; + final isFreshCache = + cachedAt != null && + DateTime.now().difference(cachedAt) <= _cachedAttestationMaxAge; - // Dynamically recompute update flags based on the currently running app version. - // This prevents showing stale/incorrect update dialogs (e.g. v4.3.4 -> v4.3.4) - // on the first open after an update. - final hasUpdate = _isVersionOlder(currentVersion, latestVersion); - final isForceUpdate = _isVersionOlder(currentVersion, minVersion); + if (isFreshCache) { + // Dynamically recompute update flags based on the currently running app version. + // This prevents showing stale/incorrect update dialogs (e.g. v4.3.4 -> v4.3.4) + // on the first open after an update. + final hasUpdate = _isVersionOlder(currentVersion, latestVersion); + final isForceUpdate = _isVersionOlder(currentVersion, minVersion); - final cachedResult = AppVersionCheckResult( - latestVersion: latestVersion, - minVersion: minVersion, - hasUpdate: hasUpdate, - isForceUpdate: isForceUpdate, - ); + final cachedResult = AppVersionCheckResult( + latestVersion: latestVersion, + minVersion: minVersion, + hasUpdate: hasUpdate, + isForceUpdate: isForceUpdate, + ); - // Run background verification asynchronously - unawaited(_runBackgroundIntegrityCheck()); + // Run background verification asynchronously + AppLogger.safeUnawait( + _runBackgroundIntegrityCheck().catchError( + (Object e, StackTrace st) => AppLogger.e( + 'SecurityService: Background integrity check failed', + e, + st, + ), + ), + 'SecurityService: background integrity check', + ); + + AppLogger.d('SecurityService: Returned cached attestation check.'); + return cachedResult; + } - AppLogger.d('SecurityService: Returned cached attestation check.'); - return cachedResult; + AppLogger.i( + 'SecurityService: Cached attestation is stale; refreshing.', + ); } on Object catch (e) { - AppLogger.w('SecurityService: Failed to parse cached attestation', e); + AppLogger.e('SecurityService: Failed to parse cached attestation', e); } } @@ -105,11 +125,12 @@ class SecurityService { 'minVersion': result.minVersion, 'hasUpdate': result.hasUpdate, 'isForceUpdate': result.isForceUpdate, + 'cachedAt': DateTime.now().toIso8601String(), }; await storage.saveAttestationResult(jsonEncode(map)); AppLogger.d('SecurityService: Cached attestation check result.'); } on Object catch (e) { - AppLogger.w('SecurityService: Failed to write attestation cache', e); + AppLogger.e('SecurityService: Failed to write attestation cache', e); } } @@ -137,7 +158,7 @@ class SecurityService { source: 'BackgroundAttestation', ); } else { - AppLogger.w( + AppLogger.e( 'SecurityService: Background check AppException ignored', e, ); @@ -170,6 +191,11 @@ class SecurityService { 'Please ensure you are using a genuine version of GhostClass from the Play Store.'; final criticalRisk = data['criticalRisk'] == true; + AppLogger.e( + 'SecurityService: Integrity verification failed. Reason: $reason', + Exception(reason), + ); + throw AppException( message: reason, type: AppExceptionType.unauthorized, @@ -194,6 +220,10 @@ class SecurityService { final hasUpdate = _isVersionOlder(currentVersion, latestVersion); final isForceUpdate = _isVersionOlder(currentVersion, minVersion); + AppLogger.d( + 'SecurityService: Integrity check passed. Current: $currentVersion, Latest: $latestVersion, Min: $minVersion', + ); + return AppVersionCheckResult( latestVersion: latestVersion, minVersion: minVersion, @@ -201,6 +231,9 @@ class SecurityService { isForceUpdate: isForceUpdate, ); } else { + AppLogger.e( + 'SecurityService: Attestation endpoint returned ${response.statusCode}', + ); throw const AppException( message: 'Security verification unavailable', type: AppExceptionType.server, @@ -223,6 +256,14 @@ class SecurityService { data?['action'] ?? 'Please ensure your device is not rooted, you are using the official app, and you have a stable internet connection.'; + final finalReason = + appCheckError ?? backendReason ?? 'App attestation failed'; + + AppLogger.e( + 'SecurityService: Security verification failed with ${e.response?.statusCode}. Reason: $finalReason', + e, + ); + throw AppException( message: appCheckError != null ? 'Device verification failed: $appCheckError' @@ -231,8 +272,7 @@ class SecurityService { details: { ...?data, 'type': 'security', - 'reason': - appCheckError ?? backendReason ?? 'App attestation failed', + 'reason': finalReason, 'appCheckError': appCheckError, 'action': action, }, diff --git a/mobile/lib/widgets/add_attendance_dialog.dart b/mobile/lib/widgets/add_attendance_dialog.dart index 9be6e41a..6a72670a 100644 --- a/mobile/lib/widgets/add_attendance_dialog.dart +++ b/mobile/lib/widgets/add_attendance_dialog.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:ghostclass/logic/attendance_utils.dart' as utils; +import 'package:ghostclass/logic/error_utils.dart'; import 'package:ghostclass/models/attendance.dart'; import 'package:ghostclass/models/course_details.dart'; import 'package:ghostclass/providers/academic_provider.dart'; @@ -44,6 +45,12 @@ class _AddAttendanceDialogState extends ConsumerState { WidgetsBinding.instance.addPostFrameCallback((_) => _prefillDefaults()); } + @override + void dispose() { + _reasonController.dispose(); + super.dispose(); + } + void _precomputeFrequencies() { final data = ref.read(dashboardProvider).value; if (data == null) return; @@ -456,13 +463,65 @@ class _AddAttendanceDialogState extends ConsumerState { } Widget _buildSubjectSelectorButton(DashboardData? data, Color primary) { - final selectedCourse = data?.courses.firstWhere( + final profile = ref.watch(authProvider).value?.profile; + final hasNoClass = + profile?.classField?.id == null || profile!.classField!.id.isEmpty; + final hasNoCourses = data == null || data.courses.isEmpty; + + if (hasNoClass || hasNoCourses) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: _getUniformFieldColor(), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.12), + ), + ), + child: Row( + children: [ + Icon( + LucideIcons.bookOpen, + size: 18, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.3), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'No courses available', + style: GoogleFonts.manrope( + fontSize: 13, + fontWeight: FontWeight.w700, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.4), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Icon( + LucideIcons.chevronDown, + size: 16, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.2), + ), + ], + ), + ); + } + + final selectedCourse = data.courses.firstWhere( (c) => c.safeId == _selectedCourseId, orElse: () => const CourseDetails(id: 0, name: 'Select Subject'), ); - final name = selectedCourse?.name ?? 'Select Subject'; - final isDisabled = - selectedCourse != null && _isCourseDisabled(selectedCourse, data); + final name = selectedCourse.name; + final isDisabled = _isCourseDisabled(selectedCourse, data); return InkWell( onTap: () => _showSubjectPickerBottomSheet(data, primary), @@ -516,7 +575,7 @@ class _AddAttendanceDialogState extends ConsumerState { if (data == null || data.courses.isEmpty) return; final isDark = Theme.of(context).brightness == Brightness.dark; - unawaited( + AppLogger.safeUnawait( showModalBottomSheet( context: context, backgroundColor: Colors.transparent, @@ -683,6 +742,12 @@ class _AddAttendanceDialogState extends ConsumerState { ), ); }, + ).catchError( + (Object e, StackTrace st) => AppLogger.e( + 'AddAttendanceDialog: showSubjectPickerBottomSheet failed', + e, + st, + ), ), ); } @@ -758,6 +823,10 @@ class _AddAttendanceDialogState extends ConsumerState { } Widget _buildSubmitButton(Color primary) { + final profile = ref.watch(authProvider).value?.profile; + final hasNoClass = + profile?.classField?.id == null || profile!.classField!.id.isEmpty; + return SizedBox( width: double.infinity, height: 52, @@ -766,7 +835,8 @@ class _AddAttendanceDialogState extends ConsumerState { (_isSubmitting || _isBlocked || _selectedSession == null || - _selectedCourseId == null) + _selectedCourseId == null || + hasNoClass) ? null : _handleSubmit, style: ElevatedButton.styleFrom( @@ -815,7 +885,11 @@ class _AddAttendanceDialogState extends ConsumerState { } on Object catch (e, st) { AppLogger.e('AddAttendanceDialog: Insert failed', e, st); if (mounted) { - ServiceToast.show(context, 'Failed to add record', isError: true); + ServiceToast.show( + context, + formatApiError(e, 'attendance'), + isError: true, + ); } } finally { if (mounted) setState(() => _isSubmitting = false); diff --git a/mobile/lib/widgets/aesthetic_refresh_indicator.dart b/mobile/lib/widgets/aesthetic_refresh_indicator.dart index 7843dd17..69793826 100644 --- a/mobile/lib/widgets/aesthetic_refresh_indicator.dart +++ b/mobile/lib/widgets/aesthetic_refresh_indicator.dart @@ -78,7 +78,7 @@ class _AestheticRefreshIndicatorState extends State { ); } } on Object catch (e) { - AppLogger.w('AestheticRefreshIndicator: Refresh failed', e); + AppLogger.e('AestheticRefreshIndicator: Refresh failed', e); } finally { if (widget.useOverlay) { try { @@ -86,7 +86,7 @@ class _AestheticRefreshIndicatorState extends State { rootNavigator.pop(); } } on Object catch (e) { - AppLogger.w('AestheticRefreshIndicator: Failed to hide overlay', e); + AppLogger.e('AestheticRefreshIndicator: Failed to hide overlay', e); } } _safeSetState(() => _isRefreshing = false); diff --git a/mobile/lib/widgets/app_footer.dart b/mobile/lib/widgets/app_footer.dart index ae389c88..2c16c763 100644 --- a/mobile/lib/widgets/app_footer.dart +++ b/mobile/lib/widgets/app_footer.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:ghostclass/config/app_config.dart'; import 'package:ghostclass/theme/app_theme.dart'; +import 'package:ghostclass/widgets/footer/footer_text_button.dart'; import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; @@ -56,7 +57,6 @@ class AppFooter extends StatelessWidget { 'Your EzyGo password is safe. We strictly do not read, store, or share your login password. GhostClass is just here to help you skip. đŸ‘ģ', color: primary.withValues(alpha: 0.08), iconColor: primary, - onTap: () {}, ), ), ], @@ -68,17 +68,17 @@ class AppFooter extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - _FooterTextButton( + FooterTextButton( icon: LucideIcons.helpCircle, label: 'Help', onTap: () => context.push('/help'), ), - _FooterTextButton( + FooterTextButton( icon: LucideIcons.shield, label: 'Legal', onTap: () => context.push('/legal'), ), - _FooterTextButton( + FooterTextButton( icon: LucideIcons.messageSquare, label: 'Contact', onTap: () => context.push('/contact'), @@ -103,25 +103,30 @@ class AppFooter extends StatelessWidget { Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - GestureDetector( - onTap: () => _launchUrl(AppConfig.authorUrl), - child: Row( - children: [ - Text( - AppConfig.authorName, - style: GoogleFonts.manrope( - fontSize: 14, - fontWeight: FontWeight.w800, - color: onSurface.withValues(alpha: 0.9), + Semantics( + button: true, + label: 'Open author website', + child: InkWell( + onTap: () => _launchUrl(AppConfig.authorUrl), + borderRadius: BorderRadius.circular(4), + child: Row( + children: [ + Text( + AppConfig.authorName, + style: GoogleFonts.manrope( + fontSize: 14, + fontWeight: FontWeight.w800, + color: onSurface.withValues(alpha: 0.9), + ), ), - ), - const SizedBox(width: 4), - Icon( - LucideIcons.externalLink, - size: 11, - color: onSurface.withValues(alpha: 0.3), - ), - ], + const SizedBox(width: 4), + Icon( + LucideIcons.externalLink, + size: 11, + color: onSurface.withValues(alpha: 0.3), + ), + ], + ), ), ), const SizedBox(height: 4), @@ -151,7 +156,7 @@ class _CompactActionTile extends StatelessWidget { required this.icon, required this.label, required this.subtitle, - required this.onTap, + this.onTap, this.gradientColors, this.color, this.iconColor, @@ -159,7 +164,7 @@ class _CompactActionTile extends StatelessWidget { final IconData icon; final String label; final String subtitle; - final VoidCallback onTap; + final VoidCallback? onTap; final List? gradientColors; final Color? color; final Color? iconColor; @@ -241,45 +246,7 @@ class _CompactActionTile extends StatelessWidget { } } -class _FooterTextButton extends StatelessWidget { - const _FooterTextButton({ - required this.icon, - required this.label, - required this.onTap, - }); - final IconData icon; - final String label; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final onSurface = theme.colorScheme.onSurface; - - return InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 16, color: onSurface.withValues(alpha: 0.5)), - const SizedBox(width: 6), - Text( - label, - style: GoogleFonts.manrope( - fontSize: 13, - fontWeight: FontWeight.w700, - color: onSurface.withValues(alpha: 0.7), - ), - ), - ], - ), - ), - ); - } -} +// FooterTextButton moved to shared widget: widgets/footer/footer_text_button.dart class _CoffeeButton extends StatelessWidget { const _CoffeeButton({required this.onTap}); diff --git a/mobile/lib/widgets/attendance/add_course_dialog.dart b/mobile/lib/widgets/attendance/add_course_dialog.dart index f2079cff..2242bcbf 100644 --- a/mobile/lib/widgets/attendance/add_course_dialog.dart +++ b/mobile/lib/widgets/attendance/add_course_dialog.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:ghostclass/logic/attendance_utils.dart' as utils; +import 'package:ghostclass/logic/error_utils.dart'; import 'package:ghostclass/providers/dashboard_provider.dart'; import 'package:ghostclass/services/api_service.dart'; import 'package:ghostclass/widgets/service_toast.dart'; @@ -49,7 +51,7 @@ class _AddCourseDialogState extends ConsumerState { final res = await api.addCourse( courseCode: _codeController.text.trim().toUpperCase(), - courseName: _nameController.text.trim(), + courseName: _nameController.text.trim().replaceAll(RegExp(r'\s+'), ' '), semester: widget.semester, academicYear: widget.academicYear, supabaseToken: supabaseToken, @@ -68,12 +70,17 @@ class _AddCourseDialogState extends ConsumerState { ); // Refresh dashboard - ref.invalidate(dashboardProvider); + await ref.read(dashboardProvider.notifier).refreshAfterCourseAdded(); + if (!mounted) return; Navigator.pop(context); } on Object catch (e) { if (!mounted) return; - ServiceToast.show(context, e.toString(), isError: true); + ServiceToast.show( + context, + formatApiError(e, 'adding course'), + isError: true, + ); } finally { if (mounted) setState(() => _isSubmitting = false); } @@ -190,7 +197,16 @@ class _AddCourseDialogState extends ConsumerState { label: 'Course Name', controller: _nameController, hint: 'Data Structures & Algorithms', - validator: (v) => v!.isEmpty ? 'Name is required' : null, + validator: (v) { + final value = v?.trim() ?? ''; + if (value.length < 2) return 'Min 2 characters'; + if (value.length > 100) return 'Max 100 characters'; + if (!utils.isValidCourseName(value)) { + return 'Course name contains invalid characters'; + } + return null; + }, + textCapitalization: TextCapitalization.words, ), const SizedBox(height: 32), diff --git a/mobile/lib/widgets/attendance/attendance_dialog_widgets.dart b/mobile/lib/widgets/attendance/attendance_dialog_widgets.dart index fe75f410..548cfcd8 100644 --- a/mobile/lib/widgets/attendance/attendance_dialog_widgets.dart +++ b/mobile/lib/widgets/attendance/attendance_dialog_widgets.dart @@ -40,35 +40,40 @@ class AttendanceStatusToggleButton extends StatelessWidget { @override Widget build(BuildContext context) { return Expanded( - child: GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(vertical: 12), - decoration: BoxDecoration( - color: isSelected - ? color.withValues(alpha: 0.1) - : Colors.transparent, - borderRadius: BorderRadius.circular(12), - border: Border.all( + child: Semantics( + button: true, + label: label ?? value, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( color: isSelected - ? color - : Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.1), - width: 1.5, - ), - ), - child: Center( - child: Text( - label ?? value, - style: GoogleFonts.manrope( - fontSize: 12, - fontWeight: FontWeight.w800, + ? color.withValues(alpha: 0.1) + : Colors.transparent, + borderRadius: BorderRadius.circular(12), + border: Border.all( color: isSelected ? color : Theme.of( context, - ).colorScheme.onSurface.withValues(alpha: 0.4), + ).colorScheme.onSurface.withValues(alpha: 0.1), + width: 1.5, + ), + ), + child: Center( + child: Text( + label ?? value, + style: GoogleFonts.manrope( + fontSize: 12, + fontWeight: FontWeight.w800, + color: isSelected + ? color + : Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.4), + ), ), ), ), diff --git a/mobile/lib/widgets/attendance/edit_instructor_dialog.dart b/mobile/lib/widgets/attendance/edit_instructor_dialog.dart index 0e69bd90..92c5cc00 100644 --- a/mobile/lib/widgets/attendance/edit_instructor_dialog.dart +++ b/mobile/lib/widgets/attendance/edit_instructor_dialog.dart @@ -1,14 +1,15 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:ghostclass/logic/attendance_utils.dart' as utils; +import 'package:ghostclass/logic/error_utils.dart'; import 'package:ghostclass/providers/academic_provider.dart'; import 'package:ghostclass/providers/auth_provider.dart'; import 'package:ghostclass/providers/dashboard_provider.dart'; +import 'package:ghostclass/services/api_service.dart'; import 'package:ghostclass/services/logger.dart'; import 'package:ghostclass/theme/app_theme.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; -import 'package:supabase_flutter/supabase_flutter.dart' as supabase; class EditInstructorDialog extends ConsumerStatefulWidget { const EditInstructorDialog({ @@ -47,9 +48,9 @@ class _EditInstructorDialogState extends ConsumerState { } Future _handleSave() async { - final name = utils.toTitleCase(_controller.text.trim()); + final name = utils.normalizePersonName(_controller.text); if (name.isEmpty) return; - if (name.length > 60) return; + if (name.length > 100) return; setState(() => _isSaving = true); @@ -58,19 +59,26 @@ class _EditInstructorDialogState extends ConsumerState { final auth = ref.read(authProvider).value; if (academic == null || auth == null) throw Exception('Missing context'); - final client = supabase.Supabase.instance.client; + final apiService = ref.read(apiServiceProvider); + final client = ref.read(supabaseClientProvider); + final supabaseToken = client.auth.currentSession?.accessToken; + if (supabaseToken == null) throw Exception('Not authenticated'); - await client.from('course_instructors').upsert({ - 'class_id': auth.profile?.classField?.id, - 'course_code': widget.courseCode.toUpperCase().replaceAll(' ', ''), - 'semester': academic.semester, - 'academic_year': academic.year, - 'instructor_name': name, - 'updated_by': auth.supabaseUserId, - }, onConflict: 'class_id, course_code, semester, academic_year'); + await apiService.upsertInstructor( + courseCode: widget.courseCode, + instructorName: name, + semester: academic.semester, + academicYear: academic.year, + supabaseToken: supabaseToken, + ); - // Refresh dashboard to show new name - await ref.read(dashboardProvider.notifier).refresh(); + // Update dashboard locally to show new name without full refresh + await ref + .read(dashboardProvider.notifier) + .updateLocalInstructor( + widget.courseCode, + name, + ); if (mounted) Navigator.pop(context); } on Object catch (e, st) { @@ -86,9 +94,9 @@ class _EditInstructorDialogState extends ConsumerState { ); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( + SnackBar( content: Text( - 'We encountered an error while saving the instructor. Please try again later. If the issue persists, please contact us.', + formatApiError(e, 'saving instructor'), ), ), ); @@ -255,7 +263,8 @@ class _EditInstructorDialogState extends ConsumerState { children: [ TextFormField( controller: _controller, - maxLength: 60, + maxLength: 100, + textCapitalization: TextCapitalization.words, style: GoogleFonts.manrope( fontWeight: FontWeight.w700, ), @@ -309,10 +318,11 @@ class _EditInstructorDialogState extends ConsumerState { if (val == null || val.trim().isEmpty) { return 'Required'; } - if (!RegExp( - r'^[a-zA-Z\s.]+$', - ).hasMatch(val.trim())) { - return 'Letters, spaces, and dots only'; + if (val.trim().length > 100) { + return 'Max 100 characters'; + } + if (!utils.isValidPersonName(val)) { + return 'Name contains invalid characters'; } return null; }, diff --git a/mobile/lib/widgets/calendar/calendar_header.dart b/mobile/lib/widgets/calendar/calendar_header.dart index cdddb8ab..007abf80 100644 --- a/mobile/lib/widgets/calendar/calendar_header.dart +++ b/mobile/lib/widgets/calendar/calendar_header.dart @@ -42,8 +42,9 @@ class CalendarHeader extends StatelessWidget { button: true, label: 'Select Month, currently ${DateFormat('MMMM yyyy').format(focusedDay)}', - child: GestureDetector( + child: InkWell( onTap: onDateSelect, + borderRadius: BorderRadius.circular(6), child: Row( children: [ Text( @@ -71,14 +72,18 @@ class CalendarHeader extends StatelessWidget { Semantics( button: true, label: 'Jump to today', - child: GestureDetector( + child: InkWell( onTap: onToday, - child: Text( - 'Jump to Today', - style: GoogleFonts.manrope( - fontSize: 12, - fontWeight: FontWeight.w700, - color: primary, + borderRadius: BorderRadius.circular(4), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Text( + 'Jump to Today', + style: GoogleFonts.manrope( + fontSize: 12, + fontWeight: FontWeight.w700, + color: primary, + ), ), ), ), @@ -123,8 +128,9 @@ class _HeaderNavButton extends StatelessWidget { button: true, label: icon == LucideIcons.chevronLeft ? 'Previous Month' : 'Next Month', enabled: enabled, - child: GestureDetector( + child: InkWell( onTap: onTap, + borderRadius: BorderRadius.circular(12), child: AnimatedOpacity( duration: const Duration(milliseconds: 200), opacity: enabled ? 1.0 : 0.3, diff --git a/mobile/lib/widgets/calendar/calendar_session_card.dart b/mobile/lib/widgets/calendar/calendar_session_card.dart index b8d9f74b..1f9e5213 100644 --- a/mobile/lib/widgets/calendar/calendar_session_card.dart +++ b/mobile/lib/widgets/calendar/calendar_session_card.dart @@ -73,6 +73,14 @@ class CalendarSessionCard extends StatelessWidget { ? disabledAccent.withValues(alpha: 0.7) : accentColor.withValues(alpha: 0.7)); + final disabledTextColor = isDark + ? Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6) + : Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.65); + + final displaySessionColor = event.isDisabled + ? disabledTextColor + : _getContrastColor(event.color, isDark); + return Container( margin: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.all(16), @@ -111,11 +119,7 @@ class CalendarSessionCard extends StatelessWidget { style: GoogleFonts.manrope( fontSize: 12, fontWeight: FontWeight.w800, - color: event.isDisabled - ? Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.4) - : event.color, + color: displaySessionColor, letterSpacing: 0.5, ), ), @@ -125,10 +129,9 @@ class CalendarSessionCard extends StatelessWidget { style: GoogleFonts.manrope( fontSize: 16, fontWeight: FontWeight.w900, - color: Theme.of(context).colorScheme.onSurface - .withValues( - alpha: event.isDisabled ? 0.4 : 1.0, - ), + color: event.isDisabled + ? disabledTextColor + : Theme.of(context).colorScheme.onSurface, ), ), if (event.courseCode != null) @@ -137,9 +140,12 @@ class CalendarSessionCard extends StatelessWidget { style: GoogleFonts.manrope( fontSize: 11, fontWeight: FontWeight.w700, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.4), + color: + Theme.of( + context, + ).colorScheme.onSurface.withValues( + alpha: isDark ? 0.6 : 0.65, + ), ), ), ], @@ -168,9 +174,7 @@ class CalendarSessionCard extends StatelessWidget { Icon( LucideIcons.eyeOff, size: 10, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.4), + color: disabledTextColor, ), const SizedBox(width: 6), ], @@ -179,7 +183,7 @@ class CalendarSessionCard extends StatelessWidget { style: GoogleFonts.manrope( fontSize: 10, fontWeight: FontWeight.w900, - color: event.color, + color: _getContrastColor(event.color, isDark), letterSpacing: 0.5, ), ), @@ -214,9 +218,12 @@ class CalendarSessionCard extends StatelessWidget { style: GoogleFonts.manrope( fontSize: 12, fontWeight: FontWeight.w600, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.5), + color: + Theme.of( + context, + ).colorScheme.onSurface.withValues( + alpha: isDark ? 0.6 : 0.65, + ), fontStyle: FontStyle.italic, ), ), @@ -232,7 +239,7 @@ class CalendarSessionCard extends StatelessWidget { child: _ActionButton( icon: LucideIcons.calendarCheck, label: 'MARK DL', - color: const Color(0xFFF59E0B), // Orange 500 + color: const Color(0xFFB45309), // Orange 700 onTap: onMarkDl, ), ), @@ -241,7 +248,7 @@ class CalendarSessionCard extends StatelessWidget { child: _ActionButton( icon: LucideIcons.checkCircle, label: 'MARK PRESENT', - color: const Color(0xFF10B981), // Green 500 + color: const Color(0xFF047857), // Green 700 onTap: onMarkPresent, ), ), @@ -283,45 +290,64 @@ class _ActionButton extends StatelessWidget { Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; - return Material( - color: Colors.transparent, - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(16), - child: Container( - width: isFullWidth ? double.infinity : null, - padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 12), - decoration: BoxDecoration( - color: color, - borderRadius: BorderRadius.circular(16), - border: isDark - ? Border.all(color: color.withValues(alpha: 0.1), width: 1.5) - : null, - boxShadow: !isDark - ? [ - BoxShadow( - color: color.withValues(alpha: 0.2), - blurRadius: 8, - offset: const Offset(0, 4), - ), - ] - : null, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(icon, size: 14, color: Colors.white), - const SizedBox(width: 8), - Text( - label, - style: GoogleFonts.manrope( - fontSize: 12, - fontWeight: FontWeight.w900, - color: Colors.white, - letterSpacing: 0.8, + // Translate label to a user friendly screen reader description + var semanticLabel = label; + if (label.toUpperCase() == 'MARK DL') { + semanticLabel = 'Mark Duty Leave'; + } else if (label.toUpperCase() == 'MARK PRESENT') { + semanticLabel = 'Mark Present'; + } else if (label.toUpperCase() == 'DELETE RECORD') { + semanticLabel = 'Delete Record'; + } + + final textColor = color.computeLuminance() > 0.45 + ? const Color(0xFF1F2937) + : Colors.white; + + return Semantics( + button: true, + enabled: onTap != null, + label: semanticLabel, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: Container( + width: isFullWidth ? double.infinity : null, + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 12), + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(16), + border: isDark + ? Border.all(color: color.withValues(alpha: 0.1), width: 1.5) + : null, + boxShadow: !isDark + ? [ + BoxShadow( + color: color.withValues(alpha: 0.2), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ] + : null, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 14, color: textColor), + const SizedBox(width: 8), + Text( + label, + style: GoogleFonts.manrope( + fontSize: 12, + fontWeight: FontWeight.w900, + color: textColor, + letterSpacing: 0.8, + ), ), - ), - ], + ], + ), ), ), ), @@ -336,6 +362,7 @@ class _CorrectionTag extends StatelessWidget { Widget build(BuildContext context) { const color = Color(0xFFA855F7); final isDark = Theme.of(context).brightness == Brightness.dark; + final displayColor = _getContrastColor(color, isDark); return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( @@ -345,14 +372,14 @@ class _CorrectionTag extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - const Icon(LucideIcons.rotateCcw, size: 10, color: color), + Icon(LucideIcons.rotateCcw, size: 10, color: displayColor), const SizedBox(width: 6), Text( 'CORRECTION', style: GoogleFonts.manrope( fontSize: 9, fontWeight: FontWeight.w900, - color: color, + color: displayColor, letterSpacing: 0.5, ), ), @@ -369,6 +396,7 @@ class _SelfMarkedTag extends StatelessWidget { Widget build(BuildContext context) { const color = Color(0xFF6366F1); final isDark = Theme.of(context).brightness == Brightness.dark; + final displayColor = _getContrastColor(color, isDark); return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( @@ -378,14 +406,14 @@ class _SelfMarkedTag extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - const Icon(LucideIcons.mousePointer2, size: 10, color: color), + Icon(LucideIcons.mousePointer2, size: 10, color: displayColor), const SizedBox(width: 6), Text( 'SELF-MARKED', style: GoogleFonts.manrope( fontSize: 9, fontWeight: FontWeight.w900, - color: color, + color: displayColor, letterSpacing: 0.5, ), ), @@ -394,3 +422,35 @@ class _SelfMarkedTag extends StatelessWidget { ); } } + +Color _getContrastColor(Color color, bool isDark) { + if (isDark) { + final luminance = color.computeLuminance(); + if (luminance >= 0.45) return color; + + final hsl = HSLColor.fromColor(color); + var lightness = hsl.lightness; + while (lightness < 1.0) { + lightness = (lightness + 0.05).clamp(0.0, 1.0); + final candidate = hsl.withLightness(lightness).toColor(); + if (candidate.computeLuminance() >= 0.45) { + return candidate; + } + } + return Colors.white; + } else { + final luminance = color.computeLuminance(); + if (luminance <= 0.18) return color; + + final hsl = HSLColor.fromColor(color); + var lightness = hsl.lightness; + while (lightness > 0.0) { + lightness = (lightness - 0.05).clamp(0.0, 1.0); + final candidate = hsl.withLightness(lightness).toColor(); + if (candidate.computeLuminance() <= 0.18) { + return candidate; + } + } + return Colors.black; + } +} diff --git a/mobile/lib/widgets/calendar/calendar_widgets.dart b/mobile/lib/widgets/calendar/calendar_widgets.dart index 5c832133..09458b32 100644 --- a/mobile/lib/widgets/calendar/calendar_widgets.dart +++ b/mobile/lib/widgets/calendar/calendar_widgets.dart @@ -91,14 +91,15 @@ class AttendanceCalendarWidget extends StatelessWidget { final status = _getDayStatus(date, context); - return GestureDetector( - onTap: () => onDaySelected(date), - child: Center( - child: Semantics( - label: - '${DateFormat('MMMM d').format(date)}${status != null ? ", $status" : ""}', - selected: isSelected, - button: true, + return Center( + child: Semantics( + label: + '${DateFormat('MMMM d').format(date)}${status != null ? ", $status" : ""}', + selected: isSelected, + button: true, + child: InkWell( + onTap: () => onDaySelected(date), + borderRadius: BorderRadius.circular(20), child: AnimatedContainer( duration: const Duration(milliseconds: 200), width: 40, diff --git a/mobile/lib/widgets/common/icon_badge.dart b/mobile/lib/widgets/common/icon_badge.dart new file mode 100644 index 00000000..c19cdc02 --- /dev/null +++ b/mobile/lib/widgets/common/icon_badge.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; + +class IconBadge extends StatelessWidget { + const IconBadge({ + required this.icon, + required this.color, + super.key, + this.size = 20, + this.padding = const EdgeInsets.all(10), + this.radius = 14.0, + this.bgAlpha = 0.1, + this.borderColor, + }); + + final IconData icon; + final Color color; + final double size; + final EdgeInsets padding; + final double radius; + final double bgAlpha; + final Color? borderColor; + + @override + Widget build(BuildContext context) { + return Container( + padding: padding, + decoration: BoxDecoration( + color: color.withValues(alpha: bgAlpha), + borderRadius: BorderRadius.circular(radius), + border: borderColor != null ? Border.all(color: borderColor!) : null, + ), + child: Icon(icon, size: size, color: color), + ); + } +} diff --git a/mobile/lib/widgets/common/pill.dart b/mobile/lib/widgets/common/pill.dart new file mode 100644 index 00000000..a8126c0f --- /dev/null +++ b/mobile/lib/widgets/common/pill.dart @@ -0,0 +1,138 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +class LabelPill extends StatelessWidget { + const LabelPill({ + required this.label, + required this.color, + super.key, + this.radius = 6.0, + this.padding = const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + }); + + final String label; + final Color color; + final double radius; + final EdgeInsets padding; + + @override + Widget build(BuildContext context) { + return Container( + padding: padding, + decoration: BoxDecoration( + color: color.withValues( + alpha: Theme.of(context).brightness == Brightness.dark ? 0.15 : 0.1, + ), + borderRadius: BorderRadius.circular(radius), + ), + child: Text( + label.toUpperCase(), + style: GoogleFonts.manrope( + fontSize: 9, + fontWeight: FontWeight.w900, + color: color, + letterSpacing: 0.5, + ), + ), + ); + } +} + +class SelectablePill extends StatelessWidget { + const SelectablePill({ + required this.label, + required this.count, + required this.isSelected, + required this.onTap, + required this.primary, + required this.surface, + super.key, + this.isDisabled = false, + }); + + final String label; + final int count; + final bool isSelected; + final VoidCallback onTap; + final Color primary; + final Color surface; + final bool isDisabled; + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return InkWell( + onTap: isDisabled ? null : onTap, + borderRadius: BorderRadius.circular(16), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: isSelected ? primary : surface, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isSelected + ? primary.withValues(alpha: isDark ? 0.35 : 0.8) + : isDisabled + ? Theme.of(context).colorScheme.outlineVariant.withValues( + alpha: isDark ? 0.2 : 0.1, + ) + : Theme.of(context).colorScheme.outlineVariant.withValues( + alpha: isDark ? 0.25 : 0.35, + ), + ), + boxShadow: isSelected && !isDark + ? [ + BoxShadow( + color: primary.withValues(alpha: 0.2), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ] + : null, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + label, + style: GoogleFonts.manrope( + fontSize: 13, + fontWeight: isSelected ? FontWeight.w800 : FontWeight.w700, + fontStyle: isDisabled ? FontStyle.italic : FontStyle.normal, + color: isSelected + ? Colors.white + : isDisabled + ? Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.3) + : Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.8), + ), + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: isSelected + ? Colors.white.withValues(alpha: 0.2) + : primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + count.toString(), + style: GoogleFonts.manrope( + fontSize: 10, + fontWeight: FontWeight.w900, + color: isSelected ? Colors.white : primary, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/widgets/dashboard/class_selection_dialog.dart b/mobile/lib/widgets/dashboard/class_selection_dialog.dart new file mode 100644 index 00000000..9378813e --- /dev/null +++ b/mobile/lib/widgets/dashboard/class_selection_dialog.dart @@ -0,0 +1,438 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:ghostclass/logic/error_utils.dart'; +import 'package:ghostclass/providers/academic_provider.dart'; +import 'package:ghostclass/providers/auth_provider.dart'; +import 'package:ghostclass/services/api_service.dart'; +import 'package:ghostclass/services/logger.dart'; +import 'package:ghostclass/widgets/service_toast.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +class ClassSelectionDialog extends ConsumerStatefulWidget { + const ClassSelectionDialog({super.key}); + + @override + ConsumerState createState() => + _ClassSelectionDialogState(); +} + +class _ClassSelectionDialogState extends ConsumerState { + String? _selectedClassId; + String? _selectedClassName; + late final List> _classes = []; + + bool _isLoadingClasses = false; + bool _isSaving = false; + String? _errorMessage; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _fetchClasses()); + } + + Future _fetchClasses() async { + setState(() { + _isLoadingClasses = true; + _errorMessage = null; + }); + + try { + final academic = ref.read(academicProvider).value; + final currentSemester = academic?.semester ?? ''; + final currentYear = academic?.year ?? ''; + + final response = await Supabase.instance.client + .from('classes') + .select('id, name') + .eq('sem', currentSemester) + .eq('year', currentYear) + .order('name', ascending: true); + + final rawList = response as List; + final parsedClasses = rawList + .map((e) => Map.from(e as Map)) + .toList(); + + setState(() { + _classes + ..clear() + ..addAll(parsedClasses); + _isLoadingClasses = false; + if (parsedClasses.isEmpty) { + _errorMessage = + 'No classes found in the database for the current term ($currentSemester $currentYear).'; + } + }); + } on Object catch (e, st) { + AppLogger.e('ClassSelectionDialog: Fetching classes failed', e, st); + setState(() { + _errorMessage = 'Failed to load classes. Please try again.'; + _isLoadingClasses = false; + }); + } + } + + Future _handleConfirm() async { + if (_selectedClassId == null) return; + + setState(() { + _isSaving = true; + _errorMessage = null; + }); + + try { + final supabaseToken = + Supabase.instance.client.auth.currentSession?.accessToken; + if (supabaseToken == null) { + throw Exception('Session expired. Please log in again.'); + } + + final api = ref.read(apiServiceProvider); + final response = await api.updateProfile(supabaseToken, { + 'class_id': _selectedClassId, + }); + + if (response.statusCode != 200) { + throw Exception(response.statusMessage ?? 'Failed to update profile.'); + } + + await ref.read(authProvider.notifier).syncProfile(); + + if (mounted) { + Navigator.of(context).pop(); + ServiceToast.show(context, 'Class assigned successfully! 🎓'); + } + } on Object catch (e, st) { + AppLogger.e('ClassSelectionDialog: Save failed', e, st); + setState(() { + _errorMessage = formatApiError(e, 'class selection'); + _isSaving = false; + }); + } + } + + Future _showClassPicker() async { + if (_classes.isEmpty) return; + + await showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (ctx) { + return Container( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.6, + ), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(28)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 12), + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 16), + Text( + 'Select Class', + style: GoogleFonts.manrope( + fontSize: 18, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 12), + Divider( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.05), + ), + Flexible( + child: ListView.builder( + shrinkWrap: true, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + itemCount: _classes.length, + itemBuilder: (context, index) { + final item = _classes[index]; + final classId = item['id']?.toString() ?? ''; + final className = + item['name']?.toString() ?? 'Unnamed Class'; + final isSelected = classId == _selectedClassId; + + return ListTile( + title: Text( + className, + style: GoogleFonts.manrope( + fontWeight: isSelected + ? FontWeight.w800 + : FontWeight.w600, + color: isSelected + ? Theme.of(context).colorScheme.primary + : null, + ), + ), + trailing: isSelected + ? Icon( + LucideIcons.check, + color: Theme.of(context).colorScheme.primary, + ) + : null, + onTap: () { + setState(() { + _selectedClassId = classId; + _selectedClassName = className; + }); + Navigator.pop(ctx); + }, + ); + }, + ), + ), + const SizedBox(height: 24), + ], + ), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + final surfaceColor = Theme.of(context).colorScheme.surface; + final primaryColor = Theme.of(context).colorScheme.primary; + final onSurfaceColor = Theme.of(context).colorScheme.onSurface; + + return PopScope( + canPop: false, + child: Dialog( + backgroundColor: Colors.transparent, + elevation: 0, + insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24), + child: Container( + constraints: const BoxConstraints(maxWidth: 400), + padding: const EdgeInsets.all(28), + decoration: BoxDecoration( + color: surfaceColor, + borderRadius: BorderRadius.circular(32), + border: Border.all( + color: onSurfaceColor.withValues(alpha: 0.08), + width: 1.5, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.4), + blurRadius: 40, + spreadRadius: 10, + offset: const Offset(0, 20), + ), + ], + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: primaryColor.withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + child: Icon( + LucideIcons.graduationCap, + color: primaryColor, + size: 32, + ), + ), + const SizedBox(height: 24), + Text( + 'Select Your Class', + textAlign: TextAlign.center, + style: GoogleFonts.manrope( + fontSize: 22, + fontWeight: FontWeight.w900, + color: onSurfaceColor, + letterSpacing: -0.5, + ), + ), + const SizedBox(height: 12), + Text( + 'Before you can access the dashboard or track attendance, please assign your academic class cohort.', + textAlign: TextAlign.center, + style: GoogleFonts.manrope( + fontSize: 14, + color: onSurfaceColor.withValues(alpha: 0.7), + height: 1.5, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 24), + if (_errorMessage != null) ...[ + Container( + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.error.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon( + LucideIcons.alertCircle, + color: Theme.of(context).colorScheme.error, + size: 18, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + _errorMessage!, + style: GoogleFonts.manrope( + fontSize: 12, + color: Theme.of(context).colorScheme.error, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ], + InkWell( + onTap: _isLoadingClasses || _isSaving + ? null + : _showClassPicker, + borderRadius: BorderRadius.circular(14), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + decoration: BoxDecoration( + color: onSurfaceColor.withValues(alpha: 0.04), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: onSurfaceColor.withValues(alpha: 0.08), + ), + ), + child: Row( + children: [ + Icon(LucideIcons.users, size: 18, color: primaryColor), + const SizedBox(width: 12), + Expanded( + child: Text( + _isLoadingClasses + ? 'Loading classes...' + : (_selectedClassName != null + ? _selectedClassName! + : 'Select Class'), + style: GoogleFonts.manrope( + fontSize: 14, + fontWeight: FontWeight.w700, + color: _selectedClassName != null + ? onSurfaceColor + : onSurfaceColor.withValues(alpha: 0.4), + ), + ), + ), + if (_isLoadingClasses) + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else + Icon( + LucideIcons.chevronDown, + size: 16, + color: onSurfaceColor.withValues(alpha: 0.4), + ), + ], + ), + ), + ), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + 'If your class is not listed, please wait until someone else in your class syncs it or until EzyGo is initialized.', + textAlign: TextAlign.center, + style: GoogleFonts.manrope( + fontSize: 12, + color: onSurfaceColor.withValues(alpha: 0.72), + height: 1.4, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + height: 56, + child: ElevatedButton( + onPressed: _selectedClassId == null || _isSaving + ? null + : _handleConfirm, + style: ElevatedButton.styleFrom( + backgroundColor: primaryColor, + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(18), + ), + ), + child: _isSaving + ? const CircularProgressIndicator(color: Colors.white) + : Text( + 'Confirm & Proceed', + style: GoogleFonts.manrope( + fontWeight: FontWeight.w800, + fontSize: 16, + ), + ), + ), + ), + const SizedBox(height: 8), + TextButton( + onPressed: _isSaving + ? null + : () async { + await ref.read(authProvider.notifier).logout(); + if (context.mounted) { + Navigator.of(context).pop(); + } + }, + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + padding: const EdgeInsets.symmetric(vertical: 12), + ), + child: Text( + 'Logout', + style: GoogleFonts.manrope( + fontWeight: FontWeight.w700, + fontSize: 14, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/widgets/dashboard/course_list_section.dart b/mobile/lib/widgets/dashboard/course_list_section.dart index 32d9e9ab..be468fe6 100644 --- a/mobile/lib/widgets/dashboard/course_list_section.dart +++ b/mobile/lib/widgets/dashboard/course_list_section.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:ghostclass/logic/attendance_utils.dart' as utils; import 'package:ghostclass/models/course_details.dart'; import 'package:ghostclass/models/course_instructor.dart'; import 'package:ghostclass/models/dashboard_stats.dart'; +import 'package:ghostclass/providers/auth_provider.dart'; import 'package:ghostclass/widgets/attendance/add_course_dialog.dart'; import 'package:ghostclass/widgets/dashboard/disable_aware_course_card.dart'; import 'package:google_fonts/google_fonts.dart'; @@ -123,7 +125,7 @@ class CourseListSection extends StatelessWidget { } } -class _AddCourseCard extends StatelessWidget { +class _AddCourseCard extends ConsumerWidget { const _AddCourseCard({ required this.semester, required this.academicYear, @@ -134,126 +136,153 @@ class _AddCourseCard extends StatelessWidget { final String? className; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final profile = ref.watch(authProvider).value?.profile; + final hasNoClass = + profile?.classField?.id == null || profile!.classField!.id.isEmpty; + return Semantics( button: true, label: 'Add a manual course to your lineup', - child: Container( - margin: const EdgeInsets.only(top: 8), - child: InkWell( - onTap: () => _showAddCourseDialog(context), - borderRadius: BorderRadius.circular(24), - child: Container( - clipBehavior: Clip.antiAlias, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(24), - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - Theme.of(context).colorScheme.primary.withValues(alpha: 0.08), - Theme.of(context).colorScheme.primary.withValues(alpha: 0.03), - ], - ), - border: Border.all( - color: Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.15), - width: 1.5, - ), - ), - child: Stack( - children: [ - // Decorative Background Circle - Positioned( - right: -20, - top: -20, - child: Container( - width: 120, - height: 120, - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: RadialGradient( - colors: [ - Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.08), - Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0), - ], - ), - ), + child: Opacity( + opacity: hasNoClass ? 0.5 : 1.0, + child: Container( + margin: const EdgeInsets.only(top: 8), + child: InkWell( + onTap: () { + if (hasNoClass) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('You have not assigned a class yet.'), + backgroundColor: Theme.of(context).colorScheme.error, ), + ); + return; + } + _showAddCourseDialog(context); + }, + borderRadius: BorderRadius.circular(24), + child: Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.08), + Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.03), + ], ), - Padding( - padding: const EdgeInsets.all(24), - child: Row( - children: [ - // Icon Container - Container( - width: 64, - height: 64, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary, - borderRadius: BorderRadius.circular(20), - boxShadow: [ - BoxShadow( - color: Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.3), - blurRadius: 12, - offset: const Offset(0, 4), - ), + border: Border.all( + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.15), + width: 1.5, + ), + ), + child: Stack( + children: [ + // Decorative Background Circle + Positioned( + right: -20, + top: -20, + child: Container( + width: 120, + height: 120, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.08), + Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0), ], ), - child: const Icon( - LucideIcons.plusCircle, - color: Colors.white, - size: 32, - ), ), - const SizedBox(width: 20), - // Text Content - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Can't find a course?", - style: GoogleFonts.manrope( - fontSize: 18, - fontWeight: FontWeight.w900, - color: Theme.of(context).colorScheme.onSurface, - letterSpacing: -0.5, - ), - ), - const SizedBox(height: 4), - Text( - 'Add it manually to your lineup\nand start tracking.', - style: GoogleFonts.manrope( - fontSize: 13, - fontWeight: FontWeight.w600, + ), + ), + Padding( + padding: const EdgeInsets.all(24), + child: Row( + children: [ + // Icon Container + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( color: Theme.of( context, - ).colorScheme.onSurface.withValues(alpha: 0.5), - height: 1.3, + ).colorScheme.primary.withValues(alpha: 0.3), + blurRadius: 12, + offset: const Offset(0, 4), ), - ), - ], + ], + ), + child: const Icon( + LucideIcons.plusCircle, + color: Colors.white, + size: 32, + ), ), - ), - Icon( - LucideIcons.chevronRight, - size: 20, - color: Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.4), - ), - ], + const SizedBox(width: 20), + // Text Content + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Can't find a course?", + style: GoogleFonts.manrope( + fontSize: 18, + fontWeight: FontWeight.w900, + color: Theme.of( + context, + ).colorScheme.onSurface, + letterSpacing: -0.5, + ), + ), + const SizedBox(height: 4), + Text( + 'Add it manually to your lineup\nand start tracking.', + style: GoogleFonts.manrope( + fontSize: 13, + fontWeight: FontWeight.w600, + color: + Theme.of( + context, + ).colorScheme.onSurface.withValues( + alpha: 0.5, + ), + height: 1.3, + ), + ), + ], + ), + ), + Icon( + LucideIcons.chevronRight, + size: 20, + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.4), + ), + ], + ), ), - ), - ], + ], + ), ), ), ), diff --git a/mobile/lib/widgets/dashboard/disable_aware_course_card.dart b/mobile/lib/widgets/dashboard/disable_aware_course_card.dart index 2d585fdd..35074c78 100644 --- a/mobile/lib/widgets/dashboard/disable_aware_course_card.dart +++ b/mobile/lib/widgets/dashboard/disable_aware_course_card.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:ghostclass/logic/attendance_utils.dart' as utils; +import 'package:ghostclass/logic/error_utils.dart'; import 'package:ghostclass/models/course_details.dart'; import 'package:ghostclass/models/course_instructor.dart'; import 'package:ghostclass/models/dashboard_stats.dart'; @@ -233,7 +234,7 @@ class _DisableAwareCourseCardState setDialogState(() => isSaving = false); ServiceToast.show( context, - 'Failed: $e', + formatApiError(e, 'course configuration'), isError: true, ); } @@ -492,7 +493,7 @@ class _DisableDialogContentState extends State { setState(() => isSaving = false); ServiceToast.show( context, - 'We encountered an error while disabling this course. Please try again later. If the issue persists, please contact us.', + formatApiError(e, 'course configuration'), isError: true, ); } diff --git a/mobile/lib/widgets/dashboard/header_section.dart b/mobile/lib/widgets/dashboard/header_section.dart index f94c20ca..5d913fee 100644 --- a/mobile/lib/widgets/dashboard/header_section.dart +++ b/mobile/lib/widgets/dashboard/header_section.dart @@ -6,13 +6,36 @@ import 'package:ghostclass/providers/dashboard_provider.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; -class HeaderSection extends ConsumerWidget { +class HeaderSection extends ConsumerStatefulWidget { const HeaderSection({required this.data, super.key}); + final DashboardData data; @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _HeaderSectionState(); +} + +class _HeaderSectionState extends ConsumerState { + bool _academicPeriodChangeLocked = false; + + @override + Widget build(BuildContext context) { final profile = ref.watch(authProvider).value?.profile; + final isUpdating = + ref.watch(academicProvider).isLoading || _academicPeriodChangeLocked; + + final currentPeriod = _AcademicPeriod( + semester: widget.data.selectedSemester, + year: widget.data.selectedYear, + ); + final previousPeriod = _shiftAcademicPeriod( + currentPeriod, + _PeriodDirection.previous, + ); + final nextPeriod = _shiftAcademicPeriod( + currentPeriod, + _PeriodDirection.next, + ); return SliverToBoxAdapter( child: Padding( @@ -20,7 +43,6 @@ class HeaderSection extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // 1. Welcome Message RichText( text: TextSpan( style: GoogleFonts.manrope( @@ -42,7 +64,6 @@ class HeaderSection extends ConsumerWidget { ), ), const SizedBox(height: 12), - // 2. Class Badge Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( @@ -57,7 +78,9 @@ class HeaderSection extends ConsumerWidget { ), ), child: Text( - (profile?.classField?.name ?? data.className ?? 'Unassigned') + (profile?.classField?.name ?? + widget.data.className ?? + 'Unassigned') .toUpperCase(), style: GoogleFonts.manrope( fontSize: 10, @@ -80,41 +103,26 @@ class HeaderSection extends ConsumerWidget { ), ), const SizedBox(height: 20), - - // 3. Selectors - Row( - children: [ - Expanded( - child: Semantics( - label: - 'Current semester: ${data.selectedSemester}. Tap to change.', - button: true, - child: _SelectorButton( - label: data.selectedSemester.toUpperCase(), - icon: LucideIcons.calendar, - onTap: () => _showSemesterPicker( - context, - ref, - data.selectedSemester, - ), - ), - ), + Semantics( + label: + 'Current academic period: ${_formatAcademicPeriod(currentPeriod)}. Use the arrows to change it.', + button: true, + child: _AcademicPeriodSwitcher( + currentPeriod: currentPeriod, + previousPeriod: previousPeriod, + nextPeriod: nextPeriod, + isBusy: isUpdating, + onPrevious: () => _requestAcademicPeriodChange( + context, + currentPeriod: currentPeriod, + targetPeriod: previousPeriod, ), - const SizedBox(width: 10), - Expanded( - child: Semantics( - label: - 'Current academic year: ${data.selectedYear}. Tap to change.', - button: true, - child: _SelectorButton( - label: data.selectedYear, - icon: LucideIcons.calendarDays, - onTap: () => - _showYearPicker(context, ref, data.selectedYear), - ), - ), + onNext: () => _requestAcademicPeriodChange( + context, + currentPeriod: currentPeriod, + targetPeriod: nextPeriod, ), - ], + ), ), ], ), @@ -122,248 +130,249 @@ class HeaderSection extends ConsumerWidget { ); } - void _showSemesterPicker( - BuildContext context, - WidgetRef ref, - String current, - ) { - final _ = showModalBottomSheet( - context: context, - useRootNavigator: true, - backgroundColor: Colors.transparent, - isScrollControlled: true, - builder: (context) => _PickerSheet( - title: 'Select Semester', - options: const ['odd', 'even'], - selected: current.toLowerCase(), - onSelected: (val) => _handleAcademicChange( - context, - ref, - type: 'semester', - value: val, - current: current, - ), - ), - ); - } + Future _requestAcademicPeriodChange( + BuildContext context, { + required _AcademicPeriod currentPeriod, + required _AcademicPeriod? targetPeriod, + }) async { + if (_academicPeriodChangeLocked || targetPeriod == null) return; - void _showYearPicker(BuildContext context, WidgetRef ref, String current) { - final currentYear = DateTime.now().year; - final years = List.generate( - (currentYear - 2022) + 1, - (i) => '${2022 + i}-${(2023 + i).toString().substring(2)}', - ); + setState(() { + _academicPeriodChangeLocked = true; + }); - final _ = showModalBottomSheet( + final confirmed = await showDialog( context: context, - useRootNavigator: true, - backgroundColor: Colors.transparent, - isScrollControlled: true, - builder: (context) => _PickerSheet( - title: 'Select Academic Year', - options: years, - selected: current, - onSelected: (val) => _handleAcademicChange( - context, - ref, - type: 'academicYear', - value: val, - current: current, + builder: (dialogContext) => AlertDialog( + title: const Text('Confirm academic period change'), + content: Text( + 'Change from ${_formatAcademicPeriod(currentPeriod)} to ${_formatAcademicPeriod(targetPeriod)}?', ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: const Text('Confirm'), + ), + ], ), ); - } - Future _handleAcademicChange( - BuildContext context, - WidgetRef ref, { - required String type, - required String value, - required String current, - }) async { - Navigator.pop(context); - if (value == current) return; + if (confirmed != true) { + if (mounted) { + setState(() { + _academicPeriodChangeLocked = false; + }); + } + return; + } - if (type == 'semester') { - await ref.read(academicProvider.notifier).setSemester(value); - } else { - await ref.read(academicProvider.notifier).setYear(value); + try { + await ref + .read(academicProvider.notifier) + .setAcademicPeriod( + targetPeriod.semester, + targetPeriod.year, + ); + } finally { + if (mounted) { + setState(() { + _academicPeriodChangeLocked = false; + }); + } } } } -class _SelectorButton extends StatelessWidget { - const _SelectorButton({ - required this.label, - required this.icon, - required this.onTap, +enum _PeriodDirection { previous, next } + +class _AcademicPeriod { + const _AcademicPeriod({required this.semester, required this.year}); + + final String semester; + final String year; +} + +class _AcademicPeriodSwitcher extends StatelessWidget { + const _AcademicPeriodSwitcher({ + required this.currentPeriod, + required this.previousPeriod, + required this.nextPeriod, + required this.isBusy, + required this.onPrevious, + required this.onNext, }); - final String label; - final IconData icon; - final VoidCallback onTap; + + final _AcademicPeriod currentPeriod; + final _AcademicPeriod? previousPeriod; + final _AcademicPeriod? nextPeriod; + final bool isBusy; + final VoidCallback onPrevious; + final VoidCallback onNext; @override Widget build(BuildContext context) { - return InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.02), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(icon, size: 16, color: Theme.of(context).colorScheme.primary), - const SizedBox(width: 8), - Text( - label, - style: GoogleFonts.manrope( - fontSize: 13, - fontWeight: FontWeight.w800, - color: Theme.of(context).colorScheme.onSurface, + final scheme = Theme.of(context).colorScheme; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: scheme.surface, + borderRadius: BorderRadius.circular(24), + border: Border.all(color: scheme.primary.withValues(alpha: 0.12)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 18, + offset: const Offset(0, 10), + ), + ], + ), + child: Row( + children: [ + _PeriodArrowButton( + icon: LucideIcons.chevronLeft, + label: previousPeriod == null + ? 'Previous academic period unavailable' + : 'Go to ${_formatAcademicPeriod(previousPeriod!)}', + onTap: isBusy || previousPeriod == null ? null : onPrevious, + ), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Academic period', + style: GoogleFonts.manrope( + fontSize: 10, + fontWeight: FontWeight.w900, + letterSpacing: 2, + color: scheme.onSurface.withValues(alpha: 0.55), + ), + ), + const SizedBox(height: 4), + Text( + _formatAcademicPeriod(currentPeriod), + textAlign: TextAlign.center, + style: GoogleFonts.manrope( + fontSize: 16, + fontWeight: FontWeight.w900, + letterSpacing: 0.8, + color: scheme.onSurface, + ), + ), + ], ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(width: 4), - Icon( - LucideIcons.chevronDown, - size: 14, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.3), ), - ], - ), + ), + _PeriodArrowButton( + icon: LucideIcons.chevronRight, + label: nextPeriod == null + ? 'Next academic period unavailable' + : 'Go to ${_formatAcademicPeriod(nextPeriod!)}', + onTap: isBusy || nextPeriod == null ? null : onNext, + ), + ], ), ); } } -class _PickerSheet extends StatelessWidget { - const _PickerSheet({ - required this.title, - required this.options, - required this.selected, - required this.onSelected, +class _PeriodArrowButton extends StatelessWidget { + const _PeriodArrowButton({ + required this.icon, + required this.label, + required this.onTap, }); - final String title; - final List options; - final String selected; - final void Function(String) onSelected; + + final IconData icon; + final String label; + final VoidCallback? onTap; @override Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: const BorderRadius.vertical(top: Radius.circular(32)), - ), - padding: const EdgeInsets.fromLTRB(24, 12, 24, 40), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Drag Handle - Container( - width: 40, - height: 4, + final scheme = Theme.of(context).colorScheme; + + return Semantics( + button: true, + label: label, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(18), + child: Container( + width: 48, + height: 48, + alignment: Alignment.center, decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(2), + color: onTap == null + ? scheme.onSurface.withValues(alpha: 0.04) + : scheme.primary.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(18), ), - ), - const SizedBox(height: 24), - Text( - title, - textAlign: TextAlign.center, - style: GoogleFonts.manrope( - fontSize: 20, - fontWeight: FontWeight.w900, - color: Theme.of(context).colorScheme.onSurface, + child: Icon( + icon, + size: 20, + color: onTap == null + ? scheme.onSurface.withValues(alpha: 0.28) + : scheme.primary, ), ), - const SizedBox(height: 24), - Flexible( - child: Scrollbar( - thumbVisibility: true, - child: SingleChildScrollView( - physics: const BouncingScrollPhysics(), - padding: const EdgeInsets.only(right: 8), - child: Column( - children: options.map((opt) { - final isSelected = - opt.toLowerCase() == selected.toLowerCase(); - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: InkWell( - onTap: () => onSelected(opt), - borderRadius: BorderRadius.circular(16), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 16, - ), - decoration: BoxDecoration( - color: isSelected - ? Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.1) - : Colors.transparent, - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: isSelected - ? Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.2) - : Colors.transparent, - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - opt.toUpperCase(), - style: GoogleFonts.manrope( - fontSize: 16, - fontWeight: isSelected - ? FontWeight.w800 - : FontWeight.w600, - color: isSelected - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.onSurface, - letterSpacing: 0.5, - ), - ), - if (isSelected) - Icon( - LucideIcons.checkCircle, - size: 20, - color: Theme.of(context).colorScheme.primary, - ), - ], - ), - ), - ), - ); - }).toList(), - ), - ), - ), - ), - ], + ), ), ); } } + +final _academicYearPattern = RegExp(r'^(\d{2}|\d{4})-(\d{2}|\d{4})$'); + +_AcademicPeriod? _shiftAcademicPeriod( + _AcademicPeriod current, + _PeriodDirection direction, +) { + final startYear = _parseAcademicYearStart(current.year); + if (startYear == null) return null; + + if (direction == _PeriodDirection.previous) { + return current.semester.toLowerCase() == 'odd' + ? _AcademicPeriod( + semester: 'even', + year: _formatAcademicYear(startYear - 1), + ) + : _AcademicPeriod( + semester: 'odd', + year: _formatAcademicYear(startYear), + ); + } + + return current.semester.toLowerCase() == 'odd' + ? _AcademicPeriod(semester: 'even', year: _formatAcademicYear(startYear)) + : _AcademicPeriod( + semester: 'odd', + year: _formatAcademicYear(startYear + 1), + ); +} + +int? _parseAcademicYearStart(String year) { + final match = _academicYearPattern.firstMatch(year.trim()); + if (match == null) return null; + + final startRaw = match.group(1)!; + final normalizedStart = startRaw.length == 2 ? '20$startRaw' : startRaw; + return int.tryParse(normalizedStart); +} + +String _formatAcademicYear(int startYear) { + return '$startYear-${(startYear + 1).toString().substring(2)}'; +} + +String _formatAcademicPeriod(_AcademicPeriod period) { + return '${period.semester.toUpperCase()} ${period.year}'; +} diff --git a/mobile/lib/widgets/dashboard/stats_grid_section.dart b/mobile/lib/widgets/dashboard/stats_grid_section.dart index 5d4b3ff2..db9848bd 100644 --- a/mobile/lib/widgets/dashboard/stats_grid_section.dart +++ b/mobile/lib/widgets/dashboard/stats_grid_section.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:ghostclass/models/dashboard_stats.dart'; +import 'package:ghostclass/widgets/common/icon_badge.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; @@ -14,6 +15,11 @@ class StatsGridSection extends StatelessWidget { @override Widget build(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + final cardWidth = (screenWidth - 40 - 12) / 2; + final targetHeight = (cardWidth / 1.8).clamp(96.0, double.infinity); + final childAspectRatio = cardWidth / targetHeight; + return SliverPadding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), sliver: SliverList( @@ -26,7 +32,7 @@ class StatsGridSection extends StatelessWidget { crossAxisCount: 2, mainAxisSpacing: 12, crossAxisSpacing: 12, - childAspectRatio: 1.8, + childAspectRatio: childAspectRatio, children: [ _StatCard( title: 'Present (+DL)', @@ -154,20 +160,12 @@ class _StatCard extends StatelessWidget { padding: const EdgeInsets.all(16), child: Row( children: [ - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: color.withValues(alpha: 0.3), - ), - ), - child: Icon( - icon, - size: isFullWidth ? 28 : 20, - color: color, - ), + IconBadge( + icon: icon, + color: color, + radius: 12, + bgAlpha: 0.2, + borderColor: color.withValues(alpha: 0.3), ), const SizedBox(width: 12), Expanded( @@ -177,6 +175,8 @@ class _StatCard extends StatelessWidget { children: [ Text( title, + maxLines: 2, + overflow: TextOverflow.ellipsis, style: GoogleFonts.manrope( fontSize: 11, fontWeight: FontWeight.w900, @@ -187,45 +187,52 @@ class _StatCard extends StatelessWidget { ), ), const SizedBox(height: 2), - Row( - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: TextBaseline.alphabetic, - children: [ - Text( - '$value', - style: GoogleFonts.manrope( - fontSize: isFullWidth ? 24 : 18, - fontWeight: FontWeight.w900, - color: color, - ), - ), - if (subtitle != null) ...[ - const SizedBox(width: 4), + FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ Text( - subtitle!, + '$value', style: GoogleFonts.manrope( - fontSize: 14, - fontWeight: FontWeight.bold, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.4), + fontSize: isFullWidth ? 24 : 18, + fontWeight: FontWeight.w900, + color: color, ), ), - ], - ...corrections.map( - (c) => Padding( - padding: const EdgeInsets.only(left: 4), - child: Text( - '${c.isNegative ? "-" : "+"}${c.value}', + if (subtitle != null) ...[ + const SizedBox(width: 4), + Text( + subtitle!, style: GoogleFonts.manrope( fontSize: 14, - fontWeight: FontWeight.w900, - color: c.color, + fontWeight: FontWeight.bold, + color: + Theme.of( + context, + ).colorScheme.onSurface.withValues( + alpha: 0.4, + ), + ), + ), + ], + ...corrections.map( + (c) => Padding( + padding: const EdgeInsets.only(left: 4), + child: Text( + '${c.isNegative ? "-" : "+"}${c.value}', + style: GoogleFonts.manrope( + fontSize: 14, + fontWeight: FontWeight.w900, + color: c.color, + ), ), ), ), - ), - ], + ], + ), ), ], ), diff --git a/mobile/lib/widgets/footer/footer_action_button.dart b/mobile/lib/widgets/footer/footer_action_button.dart new file mode 100644 index 00000000..beca3a8f --- /dev/null +++ b/mobile/lib/widgets/footer/footer_action_button.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +class FooterActionButton extends StatelessWidget { + const FooterActionButton({ + required this.icon, + required this.label, + required this.color, + required this.onTap, + super.key, + this.uppercase = false, + this.padding = const EdgeInsets.symmetric(horizontal: 18, vertical: 12), + this.borderRadius = 14.0, + }); + + final IconData icon; + final String label; + final Color color; + final VoidCallback onTap; + final bool uppercase; + final EdgeInsets padding; + final double borderRadius; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(borderRadius), + child: Container( + padding: padding, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(borderRadius), + border: Border.all(color: color.withValues(alpha: 0.15)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: color), + const SizedBox(width: 10), + Text( + uppercase ? label.toUpperCase() : label, + style: GoogleFonts.manrope( + fontSize: 9, + fontWeight: FontWeight.w900, + color: color, + letterSpacing: 0.5, + ), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/widgets/footer/footer_text_button.dart b/mobile/lib/widgets/footer/footer_text_button.dart new file mode 100644 index 00000000..fd4845aa --- /dev/null +++ b/mobile/lib/widgets/footer/footer_text_button.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +class FooterTextButton extends StatelessWidget { + const FooterTextButton({ + required this.icon, + required this.label, + required this.onTap, + super.key, + }); + + final IconData icon; + final String label; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final onSurface = Theme.of(context).colorScheme.onSurface; + + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 16, color: onSurface.withValues(alpha: 0.5)), + const SizedBox(width: 6), + Text( + label, + style: GoogleFonts.manrope( + fontSize: 13, + fontWeight: FontWeight.w700, + color: onSurface.withValues(alpha: 0.7), + ), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/widgets/ghostclass/ghostclass_branding.dart b/mobile/lib/widgets/ghostclass/ghostclass_branding.dart index e7bde22f..d7317957 100644 --- a/mobile/lib/widgets/ghostclass/ghostclass_branding.dart +++ b/mobile/lib/widgets/ghostclass/ghostclass_branding.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:ghostclass/config/app_config.dart'; +import 'package:ghostclass/widgets/footer/footer_action_button.dart'; import 'package:ghostclass/widgets/transparency_badge.dart'; import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; @@ -29,9 +30,10 @@ class GhostClassBranding extends StatelessWidget { child: Column( children: [ // Author Credits at Very Top - GestureDetector( + InkWell( onTap: () => _launchUrl(authorUrl), - behavior: HitTestBehavior.opaque, + splashColor: Colors.transparent, + highlightColor: Colors.transparent, child: Column( children: [ RichText( @@ -79,7 +81,7 @@ class GhostClassBranding extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - _FooterActionButton( + FooterActionButton( icon: LucideIcons.coffee, label: 'Buy me a Coffee', color: Theme.of(context).brightness == Brightness.dark @@ -88,7 +90,7 @@ class GhostClassBranding extends StatelessWidget { onTap: () => _launchUrl(donateUrl), ), const SizedBox(width: 8), - _FooterActionButton( + FooterActionButton( icon: LucideIcons.star, label: 'Star on GitHub', color: Theme.of(context).brightness == Brightness.dark @@ -140,50 +142,7 @@ class GhostClassBranding extends StatelessWidget { } } -class _FooterActionButton extends StatelessWidget { - const _FooterActionButton({ - required this.icon, - required this.label, - required this.color, - required this.onTap, - }); - final IconData icon; - final String label; - final Color color; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(14), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(14), - border: Border.all(color: color.withValues(alpha: 0.15)), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 14, color: color), - const SizedBox(width: 10), - Text( - label.toUpperCase(), - style: GoogleFonts.manrope( - fontSize: 9, - fontWeight: FontWeight.w900, - color: color, - letterSpacing: 0.5, - ), - ), - ], - ), - ), - ); - } -} +// FooterActionButton provided by shared widget. class _SecondaryLink extends StatelessWidget { const _SecondaryLink({required this.label, required this.onTap}); @@ -192,16 +151,21 @@ class _SecondaryLink extends StatelessWidget { @override Widget build(BuildContext context) { - return GestureDetector( + return InkWell( onTap: onTap, - behavior: HitTestBehavior.opaque, - child: Text( - label, - style: GoogleFonts.manrope( - fontSize: 10, - fontWeight: FontWeight.w800, - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), - letterSpacing: 1.2, + borderRadius: BorderRadius.circular(4), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Text( + label, + style: GoogleFonts.manrope( + fontSize: 10, + fontWeight: FontWeight.w800, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.6), + letterSpacing: 1.2, + ), ), ), ); diff --git a/mobile/lib/widgets/ghostclass/ghostclass_menu_tile.dart b/mobile/lib/widgets/ghostclass/ghostclass_menu_tile.dart index 2c6bd466..952d3abe 100644 --- a/mobile/lib/widgets/ghostclass/ghostclass_menu_tile.dart +++ b/mobile/lib/widgets/ghostclass/ghostclass_menu_tile.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:ghostclass/widgets/common/icon_badge.dart'; import 'package:google_fonts/google_fonts.dart'; class GhostClassMenuTile extends StatelessWidget { @@ -56,13 +57,10 @@ class GhostClassMenuTile extends StatelessWidget { ), child: Row( children: [ - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(14), - ), - child: Icon(icon, size: 20, color: color), + // Use shared IconBadge to reduce duplication + IconBadge( + icon: icon, + color: color, ), const SizedBox(width: 16), Expanded( diff --git a/mobile/lib/widgets/ghostclass/ghostclass_settings_card.dart b/mobile/lib/widgets/ghostclass/ghostclass_settings_card.dart index 8f11e260..733006a3 100644 --- a/mobile/lib/widgets/ghostclass/ghostclass_settings_card.dart +++ b/mobile/lib/widgets/ghostclass/ghostclass_settings_card.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:ghostclass/widgets/common/icon_badge.dart'; import 'package:google_fonts/google_fonts.dart'; class GhostClassSettingsCard extends StatelessWidget { @@ -41,9 +42,9 @@ class GhostClassSettingsCard extends StatelessWidget { absorbing: isDisabled, child: Opacity( opacity: isDisabled ? 0.5 : 1.0, - child: GestureDetector( - onTap: onTap, - behavior: HitTestBehavior.opaque, + child: InkWell( + onTap: isDisabled ? null : onTap, + borderRadius: BorderRadius.circular(20), child: Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( @@ -71,19 +72,10 @@ class GhostClassSettingsCard extends StatelessWidget { child: isFullWidth ? Row( children: [ - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: (isActive ? Colors.white : color).withValues( - alpha: 0.15, - ), - borderRadius: BorderRadius.circular(14), - ), - child: Icon( - icon, - size: 20, - color: isActive ? Colors.white : color, - ), + IconBadge( + icon: icon, + color: isActive ? Colors.white : color, + bgAlpha: 0.15, ), const SizedBox(width: 16), Expanded( diff --git a/mobile/lib/widgets/profile/profile_widgets.dart b/mobile/lib/widgets/profile/profile_widgets.dart index 839d5b00..e1cb2508 100644 --- a/mobile/lib/widgets/profile/profile_widgets.dart +++ b/mobile/lib/widgets/profile/profile_widgets.dart @@ -24,46 +24,51 @@ class ProfileHeader extends StatelessWidget { Widget build(BuildContext context) { final surface = Theme.of(context).colorScheme.surface; final onSurface = Theme.of(context).colorScheme.onSurface; - return Container( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), child: Column( children: [ Stack( children: [ - GestureDetector( - onTap: isUploadingAvatar ? null : onAvatarTap, - child: Container( - width: 80, - height: 80, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: surface, - border: Border.all( - color: primary.withValues(alpha: 0.2), - width: 4, + Semantics( + button: true, + label: 'Profile avatar', + child: InkWell( + onTap: isUploadingAvatar ? null : onAvatarTap, + borderRadius: BorderRadius.circular(40), + child: Container( + width: 80, + height: 80, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: surface, + border: Border.all( + color: primary.withValues(alpha: 0.2), + width: 4, + ), + image: avatarUrl != null + ? DecorationImage( + image: NetworkImage( + avatarUrl!, + headers: { + 'Origin': AppConfig.supabaseOrigin, + }, + ), + fit: BoxFit.cover, + ) + : null, ), - image: avatarUrl != null - ? DecorationImage( - image: NetworkImage( - avatarUrl!, - headers: { - 'Origin': AppConfig.supabaseOrigin, - }, - ), - fit: BoxFit.cover, + child: avatarUrl == null + ? Icon( + LucideIcons.user, + size: 32, + color: onSurface.withValues(alpha: 0.15), ) : null, ), - child: avatarUrl == null - ? Icon( - LucideIcons.user, - size: 32, - color: onSurface.withValues(alpha: 0.15), - ) - : null, ), ), + if (isUploadingAvatar) Positioned.fill( child: Container( @@ -78,32 +83,38 @@ class ProfileHeader extends StatelessWidget { ), ), ), + Positioned( bottom: 2, right: 2, - child: GestureDetector( - onTap: isUploadingAvatar ? null : onAvatarTap, - child: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: primary, - shape: BoxShape.circle, - border: Border.all( - color: Theme.of(context).scaffoldBackgroundColor, - width: 3, - ), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.2), - blurRadius: 10, - spreadRadius: 2, + child: Semantics( + button: true, + label: 'Change avatar', + child: InkWell( + onTap: isUploadingAvatar ? null : onAvatarTap, + borderRadius: BorderRadius.circular(24), + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: primary, + shape: BoxShape.circle, + border: Border.all( + color: Theme.of(context).scaffoldBackgroundColor, + width: 3, ), - ], - ), - child: const Icon( - LucideIcons.camera, - size: 14, - color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 10, + spreadRadius: 2, + ), + ], + ), + child: const Icon( + LucideIcons.camera, + size: 14, + color: Colors.white, + ), ), ), ), @@ -202,12 +213,14 @@ class ProfileField extends StatelessWidget { super.key, this.maxLength, this.validator, + this.textCapitalization = TextCapitalization.none, }); final String label; final TextEditingController controller; final bool enabled; final int? maxLength; final String? Function(String?)? validator; + final TextCapitalization textCapitalization; @override Widget build(BuildContext context) { @@ -229,6 +242,7 @@ class ProfileField extends StatelessWidget { enabled: enabled, validator: validator, maxLength: maxLength, + textCapitalization: textCapitalization, buildCounter: ( context, { diff --git a/mobile/lib/widgets/security_lockdown_listener.dart b/mobile/lib/widgets/security_lockdown_listener.dart index e30e16f6..a2388c11 100644 --- a/mobile/lib/widgets/security_lockdown_listener.dart +++ b/mobile/lib/widgets/security_lockdown_listener.dart @@ -4,6 +4,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:ghostclass/logic/security_utils.dart'; import 'package:ghostclass/providers/security_provider.dart'; +import 'package:ghostclass/services/logger.dart'; +import 'package:ghostclass/services/security_guard.dart'; /// SecurityLockdownListener /// ------------------------ @@ -19,21 +21,34 @@ class SecurityLockdownListener extends ConsumerWidget { // AuthNotifier handles the stream from DioService and updates this provider ref.listen(securityFailureProvider, (previous, next) { if (next != null && next.criticalRisk) { - _handleLockdown(context, next); + _handleLockdown(context, ref, next); } }); return child; } - void _handleLockdown(BuildContext context, SecurityFailureState state) { + void _handleLockdown( + BuildContext context, + WidgetRef ref, + SecurityFailureState state, + ) { + if (Platform.isIOS) { + // Wipe storage immediately on iOS since we won't show the exit button + AppLogger.safeUnawait( + ref.read(securityGuardProvider).wipeAndExit(), + 'SecurityLockdownListener: wipeAndExit', + ); + } final _ = SecurityUtils.showSecurityFailureDialog( context, title: state.message, message: state.reason ?? 'Your device failed the security verification.', technicalDetails: state.source ?? 'Unknown security context.', - retryLabel: 'Close App', - onRetry: () => exit(0), + retryLabel: Platform.isAndroid ? 'Close App' : null, + onRetry: Platform.isAndroid + ? () => ref.read(securityGuardProvider).wipeAndExit() + : null, ); } } diff --git a/mobile/lib/widgets/service_error_view.dart b/mobile/lib/widgets/service_error_view.dart index 427a8323..2186aee5 100644 --- a/mobile/lib/widgets/service_error_view.dart +++ b/mobile/lib/widgets/service_error_view.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -8,7 +10,7 @@ import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; -class ServiceErrorView extends ConsumerWidget { +class ServiceErrorView extends ConsumerStatefulWidget { const ServiceErrorView({ super.key, this.title = 'Connection Error', @@ -20,12 +22,19 @@ class ServiceErrorView extends ConsumerWidget { }); final String title; final String description; - final VoidCallback? onRetry; + final FutureOr Function()? onRetry; final bool showHome; final Object? error; @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _ServiceErrorViewState(); +} + +class _ServiceErrorViewState extends ConsumerState { + bool _isRetrying = false; + + @override + Widget build(BuildContext context) { return Center( child: SingleChildScrollView( physics: const BouncingScrollPhysics(), @@ -53,7 +62,7 @@ class ServiceErrorView extends ConsumerWidget { ), const SizedBox(height: 20), Text( - title, + widget.title, textAlign: TextAlign.center, style: GoogleFonts.manrope( fontSize: 22, @@ -62,7 +71,7 @@ class ServiceErrorView extends ConsumerWidget { ), const SizedBox(height: 10), Text( - description, + widget.description, textAlign: TextAlign.center, style: GoogleFonts.manrope( fontSize: 14, @@ -78,7 +87,26 @@ class ServiceErrorView extends ConsumerWidget { runSpacing: 12, children: [ FilledButton( - onPressed: onRetry ?? () => context.go('/'), + onPressed: _isRetrying + ? null + : () async { + setState(() { + _isRetrying = true; + }); + try { + if (widget.onRetry != null) { + await widget.onRetry!(); + } else { + context.go('/'); + } + } finally { + if (mounted) { + setState(() { + _isRetrying = false; + }); + } + } + }, style: FilledButton.styleFrom( padding: const EdgeInsets.symmetric( horizontal: 24, @@ -90,10 +118,24 @@ class ServiceErrorView extends ConsumerWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - const Icon(LucideIcons.refreshCcw, size: 16), - const SizedBox(width: 8), + if (_isRetrying) ...[ + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Colors.white, + ), + ), + ), + const SizedBox(width: 8), + ] else ...[ + const Icon(LucideIcons.refreshCcw, size: 16), + const SizedBox(width: 8), + ], Text( - 'Retry', + _isRetrying ? 'Retrying...' : 'Retry', style: GoogleFonts.manrope( fontWeight: FontWeight.w700, height: 1.1, @@ -102,7 +144,7 @@ class ServiceErrorView extends ConsumerWidget { ], ), ), - if (showHome) + if (widget.showHome) FilledButton( onPressed: () => context.go('/'), style: FilledButton.styleFrom( @@ -135,10 +177,10 @@ class ServiceErrorView extends ConsumerWidget { FilledButton( onPressed: () => SupportHelper.openContactPage( context, - subject: title, + subject: widget.title, message: 'I am experiencing an issue with the Ezygo API.\n\n' - 'Context: ${error?.toString() ?? "Unknown Error"}', + 'Context: ${widget.error?.toString() ?? "Unknown Error"}', ), style: FilledButton.styleFrom( padding: const EdgeInsets.symmetric( diff --git a/mobile/lib/widgets/service_toast.dart b/mobile/lib/widgets/service_toast.dart index a315760d..2c846e17 100644 --- a/mobile/lib/widgets/service_toast.dart +++ b/mobile/lib/widgets/service_toast.dart @@ -11,9 +11,10 @@ class ServiceToast { bool isError = false, Duration duration = const Duration(seconds: 3), }) { - if (!context.mounted) return; - - final overlay = Overlay.of(context); + final overlay = context is StatefulElement && context.state is OverlayState + ? (context.state as OverlayState) + : Overlay.maybeOf(context); + if (overlay == null) return; late OverlayEntry entry; entry = OverlayEntry( @@ -35,28 +36,9 @@ class ServiceToast { VoidCallback? onTap, Duration duration = const Duration(seconds: 4), }) { - if (!context.mounted) return; - - OverlayState? overlay; - try { - overlay = Overlay.of(context); - } on Object catch (_) { - if (context is StatefulElement && context.state is OverlayState) { - overlay = context.state as OverlayState; - } else { - void visitor(Element element) { - if (overlay != null) return; - if (element is StatefulElement && element.state is OverlayState) { - overlay = element.state as OverlayState; - return; - } - element.visitChildren(visitor); - } - - context.visitChildElements(visitor); - } - } - + final overlay = context is StatefulElement && context.state is OverlayState + ? (context.state as OverlayState) + : Overlay.maybeOf(context, rootOverlay: true); if (overlay == null) return; late OverlayEntry entry; @@ -73,7 +55,7 @@ class ServiceToast { ), ); - overlay!.insert(entry); + overlay.insert(entry); } } @@ -286,83 +268,90 @@ class _NotificationToastWidgetState extends State<_NotificationToastWidget> opacity: _opacity, child: Material( color: Colors.transparent, - child: GestureDetector( - onTap: widget.onTap, - child: ClipRRect( + child: Semantics( + button: true, + label: widget.title, + child: InkWell( + onTap: widget.onTap, borderRadius: BorderRadius.circular(20), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 14, - ), - decoration: BoxDecoration( - color: bgColor, - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: textColor.withValues(alpha: 0.1), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, ), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.15), - blurRadius: 24, - offset: const Offset(0, 12), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: textColor.withValues(alpha: 0.1), ), - ], - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: - (isDark ? Colors.blue : Colors.blueAccent) - .withValues(alpha: 0.15), - shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.15), + blurRadius: 24, + offset: const Offset(0, 12), ), - child: Icon( - LucideIcons.bell, - color: isDark ? Colors.blue : Colors.blueAccent, - size: 20, + ], + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: + (isDark ? Colors.blue : Colors.blueAccent) + .withValues(alpha: 0.15), + shape: BoxShape.circle, + ), + child: Icon( + LucideIcons.bell, + color: isDark + ? Colors.blue + : Colors.blueAccent, + size: 20, + ), ), - ), - const SizedBox(width: 14), - Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.title, - style: GoogleFonts.manrope( - color: textColor, - fontSize: 14, - fontWeight: FontWeight.w800, + const SizedBox(width: 14), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.title, + style: GoogleFonts.manrope( + color: textColor, + fontSize: 14, + fontWeight: FontWeight.w800, + ), ), - ), - const SizedBox(height: 4), - Text( - widget.body, - style: GoogleFonts.manrope( - color: subtextColor, - fontSize: 12, - fontWeight: FontWeight.w500, + const SizedBox(height: 4), + Text( + widget.body, + style: GoogleFonts.manrope( + color: subtextColor, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ], + ], + ), ), - ), - const SizedBox(width: 8), - Icon( - LucideIcons.chevronRight, - color: textColor.withValues(alpha: 0.3), - size: 16, - ), - ], + const SizedBox(width: 8), + Icon( + LucideIcons.chevronRight, + color: textColor.withValues(alpha: 0.3), + size: 16, + ), + ], + ), ), ), ), diff --git a/mobile/lib/widgets/tracking/tracking_filter_chip.dart b/mobile/lib/widgets/tracking/tracking_filter_chip.dart index a429a7b0..9f07faad 100644 --- a/mobile/lib/widgets/tracking/tracking_filter_chip.dart +++ b/mobile/lib/widgets/tracking/tracking_filter_chip.dart @@ -46,8 +46,9 @@ class TrackingFilterChip extends StatelessWidget { return Semantics( button: true, label: 'Filter by $label ${isFiltered ? "active filter" : "unfiltered"}', - child: GestureDetector( + child: InkWell( onTap: onTap, + borderRadius: BorderRadius.circular(12), child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( @@ -97,14 +98,22 @@ class TrackingFilterChip extends StatelessWidget { Semantics( button: true, label: 'Clear filter $label', - child: GestureDetector( - onTap: onClear, - child: Icon( - LucideIcons.x, - size: 14, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.2), + child: Semantics( + button: true, + label: 'Clear filter $label', + child: InkWell( + onTap: onClear, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.all(4), + child: Icon( + LucideIcons.x, + size: 14, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.2), + ), + ), ), ), ), diff --git a/mobile/lib/widgets/tracking/tracking_record_card.dart b/mobile/lib/widgets/tracking/tracking_record_card.dart index c5f92de4..9af68548 100644 --- a/mobile/lib/widgets/tracking/tracking_record_card.dart +++ b/mobile/lib/widgets/tracking/tracking_record_card.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:ghostclass/logic/attendance_utils.dart' as utils; import 'package:ghostclass/models/attendance.dart'; import 'package:ghostclass/theme/app_theme.dart'; +import 'package:ghostclass/widgets/common/pill.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:intl/intl.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; @@ -98,9 +99,9 @@ class TrackingRecordCard extends StatelessWidget { Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ - _Badge(label: typeLabel, color: typeColor), + LabelPill(label: typeLabel, color: typeColor), const SizedBox(height: 4), - _Badge( + LabelPill( label: statusText, color: _getStatusColor(context, record.attendance), ), @@ -229,33 +230,7 @@ class TrackingRecordCard extends StatelessWidget { } } -class _Badge extends StatelessWidget { - const _Badge({required this.label, required this.color}); - final String label; - final Color color; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: color.withValues( - alpha: Theme.of(context).brightness == Brightness.dark ? 0.15 : 0.1, - ), - borderRadius: BorderRadius.circular(6), - ), - child: Text( - label.toUpperCase(), - style: GoogleFonts.manrope( - fontSize: 9, - fontWeight: FontWeight.w900, - color: color, - letterSpacing: 0.5, - ), - ), - ); - } -} +// Replaced by shared LabelPill in widgets/common/pill.dart class _DeleteButton extends StatelessWidget { const _DeleteButton({required this.onPressed}); @@ -266,8 +241,9 @@ class _DeleteButton extends StatelessWidget { return Semantics( button: true, label: 'Delete tracked event', - child: GestureDetector( + child: InkWell( onTap: onPressed, + borderRadius: BorderRadius.circular(20), child: Container( padding: const EdgeInsets.all(6), decoration: BoxDecoration( diff --git a/mobile/lib/widgets/tracking/tracking_subject_picker.dart b/mobile/lib/widgets/tracking/tracking_subject_picker.dart index 0853be91..19bd0723 100644 --- a/mobile/lib/widgets/tracking/tracking_subject_picker.dart +++ b/mobile/lib/widgets/tracking/tracking_subject_picker.dart @@ -5,6 +5,7 @@ import 'package:ghostclass/models/attendance.dart'; import 'package:ghostclass/models/course_details.dart'; import 'package:ghostclass/providers/auth_provider.dart'; import 'package:ghostclass/providers/dashboard_provider.dart'; +import 'package:ghostclass/widgets/common/pill.dart'; import 'package:google_fonts/google_fonts.dart'; class TrackingSubjectPicker extends ConsumerWidget { @@ -74,7 +75,7 @@ class TrackingSubjectPicker extends ConsumerWidget { spacing: 12, runSpacing: 12, children: [ - _PickerChip( + SelectablePill( label: 'All Subjects', count: groupedByCourse.values.fold( 0, @@ -117,14 +118,14 @@ class TrackingSubjectPicker extends ConsumerWidget { officialReport: officialReport, ); final count = groupedByCourse[key]?.length ?? 0; - return _PickerChip( + return SelectablePill( label: isDisabled ? '$label (Disabled)' : label, count: count, isSelected: isSelected, - isDisabled: isDisabled, onTap: () => onSelected(key), primary: primary, surface: surface, + isDisabled: isDisabled, ); }), ], @@ -140,100 +141,4 @@ class TrackingSubjectPicker extends ConsumerWidget { } } -class _PickerChip extends StatelessWidget { - const _PickerChip({ - required this.label, - required this.count, - required this.isSelected, - required this.onTap, - required this.primary, - required this.surface, - this.isDisabled = false, - }); - final String label; - final int count; - final bool isSelected; - final VoidCallback onTap; - final Color primary; - final Color surface; - - final bool isDisabled; - - @override - Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - return InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(16), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - decoration: BoxDecoration( - color: isSelected ? primary : surface, - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: isSelected - ? primary.withValues(alpha: isDark ? 0.35 : 0.8) - : isDisabled - ? Theme.of(context).colorScheme.outlineVariant.withValues( - alpha: isDark ? 0.2 : 0.1, - ) - : Theme.of(context).colorScheme.outlineVariant.withValues( - alpha: isDark ? 0.25 : 0.35, - ), - ), - boxShadow: isSelected && !isDark - ? [ - BoxShadow( - color: primary.withValues(alpha: 0.2), - blurRadius: 8, - offset: const Offset(0, 4), - ), - ] - : null, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Text( - label, - style: GoogleFonts.manrope( - fontSize: 13, - fontWeight: isSelected ? FontWeight.w800 : FontWeight.w700, - fontStyle: isDisabled ? FontStyle.italic : FontStyle.normal, - color: isSelected - ? Colors.white - : isDisabled - ? Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.3) - : Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.8), - ), - ), - ), - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: isSelected - ? Colors.white.withValues(alpha: 0.2) - : primary.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(6), - ), - child: Text( - count.toString(), - style: GoogleFonts.manrope( - fontSize: 10, - fontWeight: FontWeight.w900, - color: isSelected ? Colors.white : primary, - ), - ), - ), - ], - ), - ), - ); - } -} +// Replaced by shared SelectablePill in widgets/common/pill.dart diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 3fc8ea44..d655893f 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -964,10 +964,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mime: dependency: transitive description: @@ -1561,26 +1561,26 @@ packages: dependency: transitive description: name: test - sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" + sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20" url: "https://pub.dev" source: hosted - version: "1.30.0" + version: "1.31.0" test_api: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.11" test_core: dependency: transitive description: name: test_core - sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" + sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34" url: "https://pub.dev" source: hosted - version: "0.6.16" + version: "0.6.17" typed_data: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 2f8beb44..338acff3 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 4.3.6+1 +version: 4.3.7+1 environment: sdk: ^3.11.4 diff --git a/mobile/scratch/check_coverage.js b/mobile/scratch/check_coverage.js deleted file mode 100644 index c877476a..00000000 --- a/mobile/scratch/check_coverage.js +++ /dev/null @@ -1,82 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - -const lcovPath = path.join(__dirname, '../coverage/lcov.info'); - -if (!fs.existsSync(lcovPath)) { - console.error("LCOV file not found at " + lcovPath); - process.exit(1); -} - -const content = fs.readFileSync(lcovPath, 'utf8'); - -const targetFiles = [ - 'lib/logic/error_handler.dart', - 'lib/screens/login_screen.dart', - 'lib/screens/navigation_shell.dart', - 'lib/screens/splash_screen.dart', - 'lib/services/api_service.dart', - 'lib/services/push_notification_service.dart', - 'lib/services/security_service.dart', - 'lib/widgets/service_toast.dart' -]; - -const records = {}; -let currentSF = null; - -content.split('\n').forEach(line => { - line = line.trim(); - if (line.startsWith('SF:')) { - const fullPath = line.substring(3); - // Find matching target file - const matched = targetFiles.find(tf => fullPath.endsWith(tf)); - if (matched) { - currentSF = matched; - records[currentSF] = { - lf: 0, - lh: 0, - uncovered: [] - }; - } else { - currentSF = null; - } - } else if (currentSF) { - if (line.startsWith('LF:')) { - records[currentSF].lf = parseInt(line.substring(3), 10); - } else if (line.startsWith('LH:')) { - records[currentSF].lh = parseInt(line.substring(3), 10); - } else if (line.startsWith('DA:')) { - const parts = line.substring(3).split(','); - const lineNum = parseInt(parts[0], 10); - const hitCount = parseInt(parts[1], 10); - if (hitCount === 0) { - records[currentSF].uncovered.push(lineNum); - } - } - } -}); - -console.log("=== FLUTTER COVERAGE REPORT ==="); -let allMet = true; -targetFiles.forEach(file => { - const rec = records[file]; - if (!rec) { - console.log(`❌ ${file}: NO COVERAGE DATA FOUND`); - allMet = false; - return; - } - const pct = rec.lf === 0 ? 100 : (rec.lh / rec.lf) * 100; - console.log(`${pct >= 90 ? '✅' : '❌'} ${file}: ${pct.toFixed(2)}% (${rec.lh}/${rec.lf} lines covered)`); - if (rec.uncovered.length > 0 && pct < 90) { - console.log(` Uncovered lines: ${rec.uncovered.join(', ')}`); - } - if (pct < 90) { - allMet = false; - } -}); - -if (allMet) { - console.log("\n🎉 SUCCESS: All modified Flutter files meet the >= 90% coverage threshold!"); -} else { - console.log("\nâš ī¸ WARNING: Some modified Flutter files do not meet the >= 90% coverage threshold!"); -} diff --git a/mobile/scratch/check_diff_coverage.js b/mobile/scratch/check_diff_coverage.js deleted file mode 100644 index 9a32fff5..00000000 --- a/mobile/scratch/check_diff_coverage.js +++ /dev/null @@ -1,142 +0,0 @@ -const { execSync } = require('child_process'); -const fs = require('fs'); -const path = require('path'); - -// 1. Get modified lines via git diff -function getGitDiffModifiedLines() { - const diffOutput = execSync('git diff -U0', { encoding: 'utf8' }); - const files = {}; - let currentFile = null; - - diffOutput.split('\n').forEach(line => { - if (line.startsWith('+++ b/')) { - currentFile = line.substring(6); - files[currentFile] = []; - } else if (line.startsWith('@@ ') && currentFile) { - // Format: @@ -line,count +line,count @@ or @@ -line +line @@ - const match = line.match(/@@\s+-\d+(?:,\d+)?\s+\+(\d+)(?:,(\d+))?\s+@@/); - if (match) { - const start = parseInt(match[1], 10); - const count = match[2] ? parseInt(match[2], 10) : 1; - for (let i = 0; i < count; i++) { - files[currentFile].push(start + i); - } - } - } - }); - - return files; -} - -// 2. Parse LCOV file -function parseLcov(lcovPath, projectRoot, prefix = '') { - if (!fs.existsSync(lcovPath)) return {}; - const content = fs.readFileSync(lcovPath, 'utf8'); - const records = {}; - let currentFile = null; - - content.split('\n').forEach(line => { - line = line.trim(); - if (line.startsWith('SF:')) { - let fullPath = line.substring(3); - if (!path.isAbsolute(fullPath)) { - fullPath = path.join(projectRoot, prefix, fullPath); - } - // Make path relative to projectRoot - currentFile = path.relative(projectRoot, fullPath); - records[currentFile] = { hits: {}, total: 0, covered: 0 }; - } else if (currentFile && line.startsWith('DA:')) { - const parts = line.substring(3).split(','); - const lineNum = parseInt(parts[0], 10); - const hitCount = parseInt(parts[1], 10); - records[currentFile].hits[lineNum] = hitCount; - records[currentFile].total++; - if (hitCount > 0) records[currentFile].covered++; - } - }); - - return records; -} - -const diffFiles = getGitDiffModifiedLines(); -const workspaceRoot = '/workspace'; -const mobileRoot = '/workspace/mobile'; - -// Load mobile and backend coverages -const mobileLcov = parseLcov(path.join(mobileRoot, 'coverage/lcov.info'), workspaceRoot, 'mobile'); -// Backend Vitest uses lcov too -const backendLcov = parseLcov(path.join(workspaceRoot, 'coverage/lcov.info'), workspaceRoot, ''); - -console.log("\n=================== DIFF COVERAGE ANALYSIS ==================="); - -let totalDiffLines = 0; -let coveredDiffLines = 0; -let hasUncoveredFiles = false; - -Object.entries(diffFiles).forEach(([file, lines]) => { - // Ignore test files and configuration/work-flows - if ( - file.includes('__tests__') || - file.includes('test/') || - file.endsWith('.json') || - file.endsWith('.yml') || - file.endsWith('.yaml') || - file.endsWith('Dockerfile') || - file.endsWith('.env') || - file.startsWith('.') - ) { - return; - } - - const isMobile = file.startsWith('mobile/'); - const coverageData = isMobile ? mobileLcov[file] : (backendLcov[file] || mobileLcov[file]); - - if (lines.length === 0) return; - - console.log(`\n📄 File: ${file}`); - console.log(` Modified/Added Lines: ${lines.join(', ')}`); - - if (!coverageData) { - console.log(` âš ī¸ No coverage report found for this file.`); - totalDiffLines += lines.length; - hasUncoveredFiles = true; - return; - } - - let fileTotal = 0; - let fileCovered = 0; - const uncovered = []; - - lines.forEach(lineNum => { - const hits = coverageData.hits[lineNum]; - if (hits !== undefined) { - fileTotal++; - totalDiffLines++; - if (hits > 0) { - fileCovered++; - coveredDiffLines++; - } else { - uncovered.push(lineNum); - } - } - }); - - const filePct = fileTotal === 0 ? 100 : (fileCovered / fileTotal) * 100; - console.log(` Diff Line Coverage: ${filePct.toFixed(2)}% (${fileCovered}/${fileTotal} lines)`); - if (uncovered.length > 0) { - console.log(` ❌ Uncovered modified lines: ${uncovered.join(', ')}`); - } -}); - -const overallPct = totalDiffLines === 0 ? 100 : (coveredDiffLines / totalDiffLines) * 100; -console.log("\n=============================================================="); -console.log(`👉 OVERALL DIFF LINE COVERAGE: ${overallPct.toFixed(2)}% (${coveredDiffLines}/${totalDiffLines} lines)`); -console.log("=============================================================="); - -if (overallPct >= 90) { - console.log("🎉 SUCCESS: Diff coverage meets or exceeds the required 90% threshold!"); - process.exit(0); -} else { - console.log("❌ FAILURE: Diff coverage is below the required 90% threshold."); - process.exit(1); -} diff --git a/mobile/test/auto_coverage_booster_test.dart b/mobile/test/auto_coverage_booster_test.dart index b5eeef42..7533491b 100644 --- a/mobile/test/auto_coverage_booster_test.dart +++ b/mobile/test/auto_coverage_booster_test.dart @@ -79,10 +79,24 @@ import 'package:ghostclass/widgets/tracking/tracking_header_widgets.dart'; import 'package:ghostclass/widgets/tracking/tracking_record_card.dart'; import 'package:ghostclass/widgets/tracking/tracking_subject_picker.dart'; import 'package:ghostclass/widgets/transparency_badge.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; import 'coverage_helper.dart'; void main() { + setUpAll(() async { + SharedPreferences.setMockInitialValues({}); + try { + await Supabase.initialize( + url: 'https://example.com', + anonKey: 'anon', + ); + } on Object catch (_) { + // already initialized + } + }); + final mockDashboard = createMockDashboardData(); final mockUser = createMockUser(); final mockTracking = TrackingState( diff --git a/mobile/test/edit_instructor_dialog_test.dart b/mobile/test/edit_instructor_dialog_test.dart deleted file mode 100644 index 50c7cd75..00000000 --- a/mobile/test/edit_instructor_dialog_test.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:ghostclass/widgets/attendance/edit_instructor_dialog.dart'; - -void main() { - testWidgets( - 'EditInstructorDialog shows fields and SAVE disabled when unchanged', - (tester) async { - await tester.pumpWidget( - MaterialApp( - home: Builder( - builder: (context) => Scaffold( - body: Center( - child: ElevatedButton( - onPressed: () => showDialog( - context: context, - builder: (_) => const EditInstructorDialog( - courseCode: 'CS101', - courseName: 'Intro', - initialName: 'Dr. Test', - ), - ), - child: const Text('Open'), - ), - ), - ), - ), - ), - ); - - await tester.tap(find.text('Open')); - await tester.pumpAndSettle(); - - expect(find.text('Edit Instructor'), findsOneWidget); - expect(find.text('Instructor Name'), findsOneWidget); - - final saveButton = tester.widget( - find.widgetWithText(ElevatedButton, 'SAVE CHANGES'), - ); - expect(saveButton.onPressed, isNull); - - // Enter a different name and verify SAVE becomes enabled - await tester.enterText(find.byType(TextFormField), 'Dr New'); - await tester.pump(); - - final saveButtonAfter = tester.widget( - find.widgetWithText(ElevatedButton, 'SAVE CHANGES'), - ); - expect(saveButtonAfter.onPressed, isNotNull); - }, - ); -} diff --git a/mobile/test/logic/attendance_utils_test.dart b/mobile/test/logic/attendance_utils_test.dart index eb38e3ab..85459f23 100644 --- a/mobile/test/logic/attendance_utils_test.dart +++ b/mobile/test/logic/attendance_utils_test.dart @@ -75,6 +75,11 @@ void main() { expect(normalizeDate('2024/01/15'), '20240115'); }); + test('handles 2-digit years', () { + expect(normalizeDate('15-01-24'), '20240115'); + expect(normalizeDate('15/01/24'), '20240115'); + }); + test('returns empty string for invalid dates', () { expect(normalizeDate('invalid'), ''); expect(normalizeDate(null), ''); diff --git a/mobile/test/logic/error_utils_test.dart b/mobile/test/logic/error_utils_test.dart index c828b7d5..334dfe42 100644 --- a/mobile/test/logic/error_utils_test.dart +++ b/mobile/test/logic/error_utils_test.dart @@ -129,11 +129,14 @@ void main() { test('handles AppException', () { const appErr = AppException( - message: 'Forbidden action', + message: 'Some internal details', type: AppExceptionType.forbidden, statusCode: 403, ); - expect(formatApiError(appErr, 'sync'), 'Forbidden action'); + expect( + formatApiError(appErr, 'sync'), + 'Access denied. Permission required.', + ); const emptyAppErr = AppException( message: '', @@ -143,6 +146,32 @@ void main() { expect(formatApiError(emptyAppErr, 'sync'), 'Failed to complete sync'); }); + test('handles 401 and 403 status codes and messages', () { + final dio401 = DioException( + requestOptions: RequestOptions(path: '/test'), + response: Response( + requestOptions: RequestOptions(path: '/test'), + statusCode: 401, + ), + ); + expect( + formatApiError(dio401, 'fetching'), + 'Session expired. Please log in again.', + ); + + final dio403 = DioException( + requestOptions: RequestOptions(path: '/test'), + response: Response( + requestOptions: RequestOptions(path: '/test'), + statusCode: 403, + ), + ); + expect( + formatApiError(dio403, 'fetching'), + 'Access denied. Permission required.', + ); + }); + test('handles various specific error codes and messages', () { expect( formatApiError({'code': '23503'}, 'action'), @@ -189,6 +218,15 @@ void main() { ); }); + test('does not redact URL paths', () { + expect( + sanitizeTechnicalDetails( + 'Request failed at https://api.example.com/v1/users/123', + ), + 'Request failed at https://api.example.com/v1/users/123', + ); + }); + test('redacts auth tokens', () { expect( sanitizeTechnicalDetails('URL parameter token=abcde12345 expired'), diff --git a/mobile/test/logic/ezygo_batch_fetcher_test.dart b/mobile/test/logic/ezygo_batch_fetcher_test.dart index e543b144..7fa210cf 100644 --- a/mobile/test/logic/ezygo_batch_fetcher_test.dart +++ b/mobile/test/logic/ezygo_batch_fetcher_test.dart @@ -164,6 +164,56 @@ void main() { expect(r.statusCode, 200); }); + test( + 'deduplicates request data regardless of map insertion order', + () async { + final fetcher = createFetcher(); + final res = Response( + requestOptions: RequestOptions(path: '/order'), + statusCode: 200, + data: {'ok': true}, + ); + + when( + () => mockDio.request( + '/order', + data: any(named: 'data'), + options: any(named: 'options'), + ), + ).thenAnswer((_) async { + await Future.delayed(const Duration(milliseconds: 50)); + return res; + }); + + final firstPayload = {'a': 1, 'b': 2}; + final secondPayload = {'b': 2, 'a': 1}; + + final results = await Future.wait([ + fetcher.fetch( + path: '/order', + token: 'token', + method: 'POST', + data: firstPayload, + ), + fetcher.fetch( + path: '/order', + token: 'token', + method: 'POST', + data: secondPayload, + ), + ]); + + expect(results[0], results[1]); + verify( + () => mockDio.request( + '/order', + data: any(named: 'data'), + options: any(named: 'options'), + ), + ).called(1); + }, + ); + test('deduplicates identical concurrent in-flight requests', () async { final fetcher = createFetcher(); final res = Response( diff --git a/mobile/test/providers/academic_provider_test.dart b/mobile/test/providers/academic_provider_test.dart index 387c7f27..fa36694d 100644 --- a/mobile/test/providers/academic_provider_test.dart +++ b/mobile/test/providers/academic_provider_test.dart @@ -1,5 +1,17 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:ghostclass/models/user.dart'; import 'package:ghostclass/providers/academic_provider.dart'; +import 'package:ghostclass/providers/auth_provider.dart'; +import 'package:ghostclass/services/api_service.dart'; +import 'package:ghostclass/services/secure_storage.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../coverage_helper.dart'; + +class MockApiService extends Mock implements ApiService {} + +class MockSecureStorageService extends Mock implements SecureStorageService {} void main() { group('AcademicState', () { @@ -30,4 +42,60 @@ void main() { expect(unknown.endDate, DateTime(2026, 6, 30, 23, 59, 59)); }); }); + + group('AcademicNotifier', () { + test( + 'uses auth-seeded academic values on startup without duplicate EzyGo fetches', + () async { + final mockApi = MockApiService(); + final mockStorage = MockSecureStorageService(); + const staleAcademic = AcademicState( + semester: 'even', + year: '2025-2026', + ); + const freshAcademic = AcademicState( + semester: 'odd', + year: '2025-2026', + ); + final user = createMockUser().copyWith( + profile: const UserProfile( + currentSemester: 'odd', + currentYear: '2025-2026', + ), + settings: UserSettings( + bunkCalculatorEnabled: true, + targetPercentage: 75, + disabledCourses: const {}, + semester: staleAcademic.semester, + academicYear: staleAcademic.year, + ), + ); + + when( + () => mockStorage.saveAcademicState(freshAcademic), + ).thenAnswer((_) async {}); + when( + mockStorage.getAcademicState, + ).thenAnswer((_) async => staleAcademic); + + final container = ProviderContainer( + overrides: [ + authProvider.overrideWith(() => MockAuthNotifier(user)), + apiServiceProvider.overrideWithValue(mockApi), + secureStorageProvider.overrideWithValue(mockStorage), + ], + ); + addTearDown(container.dispose); + + final result = await container.read(academicProvider.future); + + expect(result, freshAcademic); + verify(() => mockStorage.saveAcademicState(freshAcademic)).called(1); + verifyNever(mockApi.clearCaches); + verifyNever(() => mockApi.fetchSemester(mockStorage)); + verifyNever(() => mockApi.fetchAcademicYear(mockStorage)); + verifyNever(mockStorage.getAcademicState); + }, + ); + }); } diff --git a/mobile/test/providers/auth_provider_analytics_test.dart b/mobile/test/providers/auth_provider_analytics_test.dart index cf6f6f02..15512267 100644 --- a/mobile/test/providers/auth_provider_analytics_test.dart +++ b/mobile/test/providers/auth_provider_analytics_test.dart @@ -106,6 +106,12 @@ void main() { (_) => const Stream>.empty(), ); when(() => mockApi.clearCaches()).thenReturn(null); + when( + () => mockApi.scheduleSync( + any(), + force: any(named: 'force'), + ), + ).thenAnswer((_) async {}); when( () => mockApi.refreshProfile( any(), @@ -273,6 +279,67 @@ void main() { }, ); + test( + 'refreshProfile deduplicates concurrent profile refresh requests', + () async { + final refreshGate = Completer>(); + var refreshCalls = 0; + + when( + () => mockApi.refreshProfile( + any(), + sync: any(named: 'sync'), + force: any(named: 'force'), + ), + ).thenAnswer((_) { + refreshCalls += 1; + return refreshGate.future; + }); + + final container = buildContainer(); + addTearDown(container.dispose); + final notifier = container.read(authProvider.notifier); + + await container.read(authProvider.future); + + notifier.state = AsyncValue.data( + AuthenticatedUser( + supabaseUserId: 'supabase-user', + ezygoToken: EncryptedValue.fromPlaintext('ezygo-token'), + settings: UserSettings.defaults(), + profile: const UserProfile(firstName: 'Test'), + ), + ); + + currentSession = mockSession; + + final first = notifier.refreshProfile(force: true); + final second = notifier.refreshProfile(force: true); + + await Future.delayed(Duration.zero); + expect(refreshCalls, 1); + + refreshGate.complete( + Response( + requestOptions: RequestOptions(path: '/profile'), + statusCode: 200, + data: { + 'profile': {'first_name': 'Test'}, + 'settings': { + 'bunk_calculator_enabled': true, + 'target_percentage': 75, + 'disabled_courses': {}, + }, + 'ezygo_token': 'ezygo-token', + }, + ), + ); + + await Future.wait([first, second]); + expect(refreshCalls, 1); + }, + ); + test('updateSettings logs analytics for changed settings', () async { await initAnalytics(); @@ -426,7 +493,7 @@ void main() { ), ); when( - () => mockApi.triggerSync(any(), force: any(named: 'force')), + () => mockApi.scheduleSync(any(), force: any(named: 'force')), ).thenAnswer( (_) async => Response( requestOptions: RequestOptions(path: '/sync'), @@ -467,7 +534,6 @@ void main() { await notifier.updateAcademicContext('Even', '2025-2026'); verify(() => mockApi.clearCaches()).called(1); - verify(() => mockApi.triggerSync(any(), force: true)).called(1); expect(refreshCount, equals(1)); final user = container.read(authProvider).value; diff --git a/mobile/test/services/auth_profile_settings_coverage_test.dart b/mobile/test/services/auth_profile_settings_coverage_test.dart index 8cad702f..1b603706 100644 --- a/mobile/test/services/auth_profile_settings_coverage_test.dart +++ b/mobile/test/services/auth_profile_settings_coverage_test.dart @@ -63,6 +63,7 @@ void main() { group('AuthService Coverage', () { test('loginEzygo passes standard request data', () async { + Map? capturedData; when( () => mockDio.post( any(), @@ -70,15 +71,43 @@ void main() { options: any(named: 'options'), ), ).thenAnswer( - (_) async => Response( - requestOptions: RequestOptions(path: '/auth'), - statusCode: 200, - data: {'token': 'ezygo_token'}, - ), + (invocation) async { + capturedData = + invocation.namedArguments[#data] as Map?; + return Response( + requestOptions: RequestOptions(path: '/auth'), + statusCode: 200, + data: {'token': 'ezygo_token'}, + ); + }, ); final resp = await authService.loginEzygo('username', 'password'); expect(resp.statusCode, 200); + expect(capturedData?['username'], 'username'); + expect(capturedData?['password'], 'password'); + }); + + test('loginEzygo preserves password whitespace', () async { + Map? capturedData; + when( + () => mockDio.post( + any(), + data: any(named: 'data'), + options: any(named: 'options'), + ), + ).thenAnswer((invocation) async { + capturedData = + invocation.namedArguments[#data] as Map?; + return Response( + requestOptions: RequestOptions(path: '/auth'), + statusCode: 200, + data: {'token': 'ezygo_token'}, + ); + }); + + await authService.loginEzygo('username', ' password '); + expect(capturedData?['password'], ' password '); }); test('loginAndProvision handles successful path', () async { @@ -172,6 +201,9 @@ void main() { when( () => mockStorage.getEzygoToken(), ).thenAnswer((_) async => 'stored_token'); + when( + () => mockStorage.getNormalizedEzygoToken(), + ).thenAnswer((_) async => 'stored_token'); await authService.refreshProfile('sub', sync: true); await authService.refreshProfile('sub'); @@ -189,6 +221,9 @@ void main() { // test getUser with null token when(() => mockStorage.getEzygoToken()).thenAnswer((_) async => null); + when( + () => mockStorage.getNormalizedEzygoToken(), + ).thenAnswer((_) async => null); await authService.getUser(mockStorage); }, ); diff --git a/mobile/test/services/dio_service_coverage_test.dart b/mobile/test/services/dio_service_coverage_test.dart index bf0911cf..5d14f272 100644 --- a/mobile/test/services/dio_service_coverage_test.dart +++ b/mobile/test/services/dio_service_coverage_test.dart @@ -46,7 +46,6 @@ void main() { dioService = container.read(dioServiceProvider); dioService.dio.httpClientAdapter = mockAdapter; - dioService.securityDio.httpClientAdapter = mockAdapter; when( () => mockAdapter.fetch(any(), any(), any()), diff --git a/mobile/test/services/push_notification_service_test.dart b/mobile/test/services/push_notification_service_test.dart index e870e3bd..8dffdcad 100644 --- a/mobile/test/services/push_notification_service_test.dart +++ b/mobile/test/services/push_notification_service_test.dart @@ -35,6 +35,8 @@ class MockSession extends Mock implements Session {} class MockFirebaseAnalytics extends Mock implements FirebaseAnalytics {} +class MockGoRouter extends Mock implements GoRouter {} + void main() { late MockFirebaseMessaging mockMessaging; late MockNotificationSettings mockSettings; @@ -68,6 +70,9 @@ void main() { when(() => mockSession.isExpired).thenReturn(false); when(() => mockStorage.getFcmToken()).thenAnswer((_) async => null); + when( + () => mockStorage.getNormalizedFcmToken(), + ).thenAnswer((_) async => null); when(() => mockStorage.saveFcmToken(any())).thenAnswer((_) async => true); when( @@ -555,6 +560,7 @@ void main() { tester, ) async { final foregroundMessages = StreamController(); + final mockGoRouter = MockGoRouter(); when( () => mockSettings.authorizationStatus, @@ -586,6 +592,10 @@ void main() { ), ); + when( + () => mockGoRouter.routerDelegate, + ).thenThrow(Exception('RouterDelegate crash')); + await tester.pumpWidget( ProviderScope( overrides: [ @@ -593,7 +603,7 @@ void main() { dioServiceProvider.overrideWithValue(mockDioService), secureStorageProvider.overrideWithValue(mockStorage), supabaseClientProvider.overrideWithValue(mockSupabase), - routerProvider.overrideWith((ref) => throw Exception('Router crash')), + routerProvider.overrideWithValue(mockGoRouter), ], child: const MaterialApp( home: Scaffold(body: SizedBox()), diff --git a/mobile/test/services/security_service_coverage_test.dart b/mobile/test/services/security_service_coverage_test.dart index d836ef63..8764cd0b 100644 --- a/mobile/test/services/security_service_coverage_test.dart +++ b/mobile/test/services/security_service_coverage_test.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:dio/dio.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -51,6 +53,46 @@ void main() { }); group('SecurityService Coverage', () { + test( + 'verifyIntegrity refreshes stale cached attestation before returning', + () async { + when( + () => mockSecureStorage.getAttestationResult(), + ).thenAnswer( + (_) async => jsonEncode({ + 'latestVersion': AppConfig.appVersion, + 'minVersion': AppConfig.appVersion, + 'cachedAt': DateTime.now() + .subtract(const Duration(hours: 7)) + .toIso8601String(), + }), + ); + + final options = RequestOptions(path: '/api/security/attestation'); + when( + () => mockDio.get(any(), options: any(named: 'options')), + ).thenAnswer( + (_) async => Response( + requestOptions: options, + statusCode: 200, + data: { + 'verified': true, + 'latestVersion': AppConfig.appVersion, + 'minVersion': AppConfig.appVersion, + }, + ), + ); + + final result = await securityService.verifyIntegrity(); + + expect(result, isNotNull); + expect(result!.hasUpdate, isFalse); + verify( + () => mockDio.get(any(), options: any(named: 'options')), + ).called(1); + }, + ); + test( 'verifyIntegrity throws security exception on 403 with appCheckError', () async { @@ -330,8 +372,28 @@ void main() { test( 'verifyIntegrity recomputes hasUpdate and isForceUpdate from cache against AppConfig.appVersion', () async { - final cachedJson = - '{"latestVersion": "${AppConfig.appVersion}", "minVersion": "${AppConfig.appVersion}", "hasUpdate": true, "isForceUpdate": true}'; + final cachedJson = jsonEncode({ + 'latestVersion': AppConfig.appVersion, + 'minVersion': AppConfig.appVersion, + 'hasUpdate': true, + 'isForceUpdate': true, + 'cachedAt': DateTime.now().toIso8601String(), + }); + + final options = RequestOptions(path: '/api/security/attestation'); + when( + () => mockDio.get(any(), options: any(named: 'options')), + ).thenAnswer( + (_) async => Response( + requestOptions: options, + statusCode: 200, + data: { + 'verified': true, + 'latestVersion': AppConfig.appVersion, + 'minVersion': AppConfig.appVersion, + }, + ), + ); when( () => mockSecureStorage.getAttestationResult(), diff --git a/mobile/test/widgets/attendance/edit_instructor_dialog_test.dart b/mobile/test/widgets/attendance/edit_instructor_dialog_test.dart new file mode 100644 index 00000000..6bebfbab --- /dev/null +++ b/mobile/test/widgets/attendance/edit_instructor_dialog_test.dart @@ -0,0 +1,169 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ghostclass/logic/encrypted_value.dart'; +import 'package:ghostclass/models/user.dart'; +import 'package:ghostclass/providers/academic_provider.dart'; +import 'package:ghostclass/providers/auth_provider.dart'; +import 'package:ghostclass/providers/dashboard_provider.dart'; +import 'package:ghostclass/services/api_service.dart'; +import 'package:ghostclass/widgets/attendance/edit_instructor_dialog.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:supabase_flutter/supabase_flutter.dart' as supabase; + +import '../../coverage_helper.dart'; + +class MockApiService extends Mock implements ApiService {} + +class MockSupabaseClient extends Mock implements supabase.SupabaseClient {} + +class MockGoTrueClient extends Mock implements supabase.GoTrueClient {} + +class MockSession extends Mock implements supabase.Session {} + +class TestMockDashboardNotifier extends MockDashboardNotifier { + TestMockDashboardNotifier(super.data); + + @override + Future refresh() async { + // No-op for testing + } + + @override + Future updateLocalInstructor( + String courseCode, + String instructorName, + ) async { + // No-op for testing + } +} + +void main() { + late MockApiService mockApi; + late MockSupabaseClient mockSupabase; + late MockGoTrueClient mockAuth; + late MockSession mockSession; + + setUp(() { + mockApi = MockApiService(); + mockSupabase = MockSupabaseClient(); + mockAuth = MockGoTrueClient(); + mockSession = MockSession(); + + when(() => mockSupabase.auth).thenReturn(mockAuth); + when(() => mockAuth.currentSession).thenReturn(mockSession); + when(() => mockSession.accessToken).thenReturn('test-supabase-token'); + }); + + testWidgets( + 'EditInstructorDialog shows fields, disables SAVE when unchanged, and calls API on save', + (tester) async { + final mockUserVal = AuthenticatedUser( + supabaseUserId: 'user-123', + username: 'testuser', + settings: UserSettings.defaults(), + ezygoToken: EncryptedValue.fromPlaintext('testtoken'), + profile: UserProfile( + firstName: 'Test', + classField: UserClass(id: 'class-456', name: 'Class 456'), + ), + ); + + const mockAcademicVal = AcademicState( + semester: 'odd', + year: '2024-2025', + ); + + when( + () => mockApi.upsertInstructor( + courseCode: any(named: 'courseCode'), + instructorName: any(named: 'instructorName'), + semester: any(named: 'semester'), + academicYear: any(named: 'academicYear'), + supabaseToken: any(named: 'supabaseToken'), + ), + ).thenAnswer( + (_) async => Response( + requestOptions: RequestOptions(path: '/api/instructors/upsert'), + statusCode: 200, + data: {'message': 'Success'}, + ), + ); + + final overrides = [ + authProvider.overrideWith( + () => MockAuthNotifier(mockUserVal), + ), + academicProvider.overrideWith( + () => MockAcademicNotifier(mockAcademicVal), + ), + apiServiceProvider.overrideWithValue(mockApi), + supabaseClientProvider.overrideWithValue(mockSupabase), + dashboardProvider.overrideWith( + () => TestMockDashboardNotifier(createMockDashboardData()), + ), + ]; + + await tester.pumpWidget( + ProviderScope( + overrides: overrides, + child: MaterialApp( + home: Builder( + builder: (context) => Scaffold( + body: Center( + child: ElevatedButton( + onPressed: () => showDialog( + context: context, + builder: (_) => const EditInstructorDialog( + courseCode: 'CS101', + courseName: 'Intro', + initialName: 'Dr. Test', + ), + ), + child: const Text('Open'), + ), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.text('Edit Instructor'), findsOneWidget); + expect(find.text('Instructor Name'), findsOneWidget); + + final saveButton = tester.widget( + find.widgetWithText(ElevatedButton, 'SAVE CHANGES'), + ); + expect(saveButton.onPressed, isNull); + + // Enter a different name and verify SAVE becomes enabled + await tester.enterText(find.byType(TextFormField), 'Dr. New'); + await tester.pump(); + + final saveButtonAfter = tester.widget( + find.widgetWithText(ElevatedButton, 'SAVE CHANGES'), + ); + expect(saveButtonAfter.onPressed, isNotNull); + + // Tap SAVE CHANGES + await tester.tap(find.widgetWithText(ElevatedButton, 'SAVE CHANGES')); + await tester.pump(); + + // Verify API was called + verify( + () => mockApi.upsertInstructor( + courseCode: 'CS101', + instructorName: 'Dr. New', + semester: 'odd', + academicYear: '2024-2025', + supabaseToken: 'test-supabase-token', + ), + ).called(1); + }, + ); +} diff --git a/next.config.ts b/next.config.ts index 9464fb9c..60be9605 100644 --- a/next.config.ts +++ b/next.config.ts @@ -46,20 +46,32 @@ const allowedImageHostnames = (() => { "avatars.githubusercontent.com", // GitHub "secure.gravatar.com", // Gravatar ]; - if (process.env.NEXT_PUBLIC_SUPABASE_URL) { - try { hosts.push(new URL(process.env.NEXT_PUBLIC_SUPABASE_URL).hostname); } catch { /* ignore */ } - } - if (process.env.NEXT_PUBLIC_SUPABASE_DEV_URL) { - try { hosts.push(new URL(process.env.NEXT_PUBLIC_SUPABASE_DEV_URL).hostname); } catch { /* ignore */ } + + const envUrls = [ + process.env.NEXT_PUBLIC_SUPABASE_URL, + process.env.NEXT_PUBLIC_SUPABASE_DEV_URL, + process.env.NEXT_PUBLIC_SUPABASE_CF_PROXY_URL, + process.env.NEXT_PUBLIC_SUPABASE_AWS_PROXY_URL, + process.env.NEXT_PUBLIC_SUPABASE_DEV_PROXY_URL, + ]; + + for (const urlString of envUrls) { + if (urlString) { + try { + hosts.push(new URL(urlString).hostname); + } catch { + /* ignore */ + } + } } - - if (hosts.length === 3 && !process.env.NEXT_PUBLIC_SUPABASE_URL) { - // We only throw if even Supabase is missing, as that's the primary storage + + if (!envUrls.some(Boolean)) { throw new Error( - '[next.config.ts] At least one Supabase URL (NEXT_PUBLIC_SUPABASE_URL or NEXT_PUBLIC_SUPABASE_DEV_URL) ' + + '[next.config.ts] At least one Supabase URL or Supabase Proxy URL ' + 'is required at build time for images.remotePatterns.' ); } + // Deduplicate return Array.from(new Set(hosts)); })(); diff --git a/package-lock.json b/package-lock.json index 1d2466c0..72537ca7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ghostclass", - "version": "4.3.6", + "version": "4.3.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ghostclass", - "version": "4.3.6", + "version": "4.3.7", "dependencies": { "@hookform/resolvers": "^5.2.2", "@radix-ui/react-alert-dialog": "^1.1.15", @@ -29,7 +29,7 @@ "@serwist/next": "^9.5.6", "@supabase/ssr": "^0.10.2", "@supabase/supabase-js": "^2.97.0", - "@tanstack/react-query": "^5.90.21", + "@tanstack/react-query": "^5.100.13", "@tanstack/react-virtual": "^3.13.21", "@upstash/ratelimit": "^2.0.8", "@upstash/redis": "^1.36.2", @@ -39,7 +39,7 @@ "date-fns": "^4.1.0", "firebase-admin": "^13.8.0", "framer-motion": "^12.34.3", - "googleapis": "^171.4.0", + "googleapis": "^172.0.0", "jose": "^6.2.2", "ldrs": "^1.1.9", "lodash-es": "^4.17.23", @@ -47,7 +47,7 @@ "lucide-react": "^1.16.0", "next": "^16.1.6", "nextjs-toploader": "^3.9.17", - "node-domexception": "^1.0.0", + "node-domexception": "^2.0.2", "react": "^19.2.4", "react-day-picker": "^10.0.0", "react-dom": "^19.2.4", @@ -64,7 +64,7 @@ "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", "tw-animate-css": "^1.4.0", - "uuid": "^11.0.5", + "uuid": "^14.0.0", "zod": "^4.3.6" }, "devDependencies": { @@ -112,9 +112,9 @@ } }, "node_modules/@adobe/css-tools": { - "version": "4.4.4", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", - "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", "dev": true, "license": "MIT" }, @@ -276,12 +276,6 @@ "yallist": "^3.0.2" } }, - "node_modules/@babel/helper-compilation-targets/node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "license": "ISC" - }, "node_modules/@babel/helper-globals": { "version": "7.28.0", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", @@ -595,9 +589,9 @@ } }, "node_modules/@date-fns/tz": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz", - "integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.5.0.tgz", + "integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==", "license": "MIT" }, "node_modules/@emnapi/core": { @@ -1207,9 +1201,9 @@ } }, "node_modules/@exodus/bytes": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", - "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", "dev": true, "license": "MIT", "engines": { @@ -1560,9 +1554,9 @@ } }, "node_modules/@grpc/grpc-js": { - "version": "1.14.3", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", - "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", + "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", "license": "Apache-2.0", "optional": true, "dependencies": { @@ -1612,9 +1606,9 @@ } }, "node_modules/@hookform/resolvers": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz", - "integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==", + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.4.0.tgz", + "integrity": "sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==", "license": "MIT", "dependencies": { "@standard-schema/utils": "^0.3.0" @@ -1782,6 +1776,9 @@ "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1798,6 +1795,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1814,6 +1814,9 @@ "cpu": [ "ppc64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1830,6 +1833,9 @@ "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1846,6 +1852,9 @@ "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1862,6 +1871,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1878,6 +1890,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1894,6 +1909,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1910,6 +1928,9 @@ "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1932,6 +1953,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1954,6 +1978,9 @@ "cpu": [ "ppc64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1976,6 +2003,9 @@ "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1998,6 +2028,9 @@ "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2020,6 +2053,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2042,6 +2078,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2064,6 +2103,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2155,19 +2197,6 @@ "url": "https://opencollective.com/libvips" } }, - "node_modules/@isaacs/fs-minipass": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", - "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.4" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -2197,17 +2226,6 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/source-map": { - "version": "0.3.11", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", - "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25" - } - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -2236,16 +2254,22 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.12", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", - "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@tybys/wasm-util": "^0.10.0" + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, "node_modules/@next/env": { @@ -2303,6 +2327,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2319,6 +2346,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2335,6 +2365,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2351,6 +2384,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2891,9 +2927,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.130.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz", - "integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==", + "version": "0.132.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", + "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", "dev": true, "license": "MIT", "funding": { @@ -2904,7 +2940,7 @@ "version": "1.60.0", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "playwright": "1.60.0" @@ -2998,9 +3034,9 @@ "optional": true }, "node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", "license": "BSD-3-Clause", "optional": true }, @@ -4339,9 +4375,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz", - "integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", + "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", "cpu": [ "arm64" ], @@ -4356,9 +4392,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz", - "integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", + "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", "cpu": [ "arm64" ], @@ -4373,9 +4409,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz", - "integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", + "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", "cpu": [ "x64" ], @@ -4390,9 +4426,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz", - "integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", + "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", "cpu": [ "x64" ], @@ -4407,9 +4443,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz", - "integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", + "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", "cpu": [ "arm" ], @@ -4424,13 +4460,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz", - "integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", + "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4441,13 +4480,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz", - "integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", + "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -4458,13 +4500,16 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz", - "integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", + "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4475,13 +4520,16 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz", - "integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", + "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4492,13 +4540,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz", - "integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", + "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4509,13 +4560,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz", - "integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", + "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -4526,9 +4580,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz", - "integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", + "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", "cpu": [ "arm64" ], @@ -4543,9 +4597,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz", - "integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", + "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", "cpu": [ "wasm32" ], @@ -4561,29 +4615,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz", - "integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", + "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", "cpu": [ "arm64" ], @@ -4598,9 +4633,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz", - "integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", + "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", "cpu": [ "x64" ], @@ -4754,6 +4789,9 @@ "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4767,6 +4805,9 @@ "cpu": [ "arm" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -4780,6 +4821,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4793,6 +4837,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -4806,6 +4853,9 @@ "cpu": [ "loong64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4819,6 +4869,9 @@ "cpu": [ "loong64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -4832,6 +4885,9 @@ "cpu": [ "ppc64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4845,6 +4901,9 @@ "cpu": [ "ppc64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -4858,6 +4917,9 @@ "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4871,6 +4933,9 @@ "cpu": [ "riscv64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -4884,6 +4949,9 @@ "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4897,6 +4965,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4910,6 +4981,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -5002,14 +5076,14 @@ "license": "MIT" }, "node_modules/@scalar/client-side-rendering": { - "version": "0.1.9", - "resolved": "https://registry.npmjs.org/@scalar/client-side-rendering/-/client-side-rendering-0.1.9.tgz", - "integrity": "sha512-Gv3CK0X+BqXYkVJLTQXNM8YgdquhuGV4hKlsM8S9k5GGonj8LBDSAiuU3W48JGX1pY7hRotBGcIIU+1a1X1mcw==", + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@scalar/client-side-rendering/-/client-side-rendering-0.1.11.tgz", + "integrity": "sha512-0Iugq4wtkH8tLMezyabD7VxEXx66KpUxZ2zlw4BCmQKbQbcupnV2KxfgahTk6LuDBMkiUyFPj9YTFeLAAMoYmQ==", "license": "MIT", "dependencies": { - "@scalar/schemas": "0.2.0", - "@scalar/types": "0.11.0", - "@scalar/validation": "0.5.0" + "@scalar/schemas": "0.3.1", + "@scalar/types": "0.12.1", + "@scalar/validation": "0.6.0" }, "engines": { "node": ">=22" @@ -5025,12 +5099,12 @@ } }, "node_modules/@scalar/nextjs-api-reference": { - "version": "0.10.16", - "resolved": "https://registry.npmjs.org/@scalar/nextjs-api-reference/-/nextjs-api-reference-0.10.16.tgz", - "integrity": "sha512-1Vfoy98MgfkGrqkAQiPTmkPoZWBm6KfpjqpumHRpRlUkHnKxXRDepHCjaF6qZbN9P2pBq/SDFNJEGYx07mdVgQ==", + "version": "0.10.18", + "resolved": "https://registry.npmjs.org/@scalar/nextjs-api-reference/-/nextjs-api-reference-0.10.18.tgz", + "integrity": "sha512-jTOvSzSP/1MnjmspZkTP/snTwh0zu3a3N87d2nIUyHpm9CJT0rzH9vDFddIqtO644yokdicacCu7OACy2P7twQ==", "license": "MIT", "dependencies": { - "@scalar/client-side-rendering": "0.1.9" + "@scalar/client-side-rendering": "0.1.11" }, "engines": { "node": ">=22" @@ -5041,21 +5115,22 @@ } }, "node_modules/@scalar/schemas": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@scalar/schemas/-/schemas-0.2.0.tgz", - "integrity": "sha512-FOpNecNoEKo8SogHEsdWlVRN4Q4PH3dzuOBWMA5tGt1xLZu5iFnPG2X/h6+Z/mOR34c7iW+hiKTqdUZoDgT9CA==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@scalar/schemas/-/schemas-0.3.1.tgz", + "integrity": "sha512-vTAhka3M9EGVXx909d6MGvekCnUt2Lz/Hz4MIoM7oLP9LxjwPm0ZMX6PWSYtZrxDji9ASqfwd/9jcIKMf7jPlA==", "license": "MIT", "dependencies": { - "@scalar/validation": "0.5.0" + "@scalar/helpers": "0.8.0", + "@scalar/validation": "0.6.0" }, "engines": { "node": ">=22" } }, "node_modules/@scalar/types": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@scalar/types/-/types-0.11.0.tgz", - "integrity": "sha512-TGZR8sys1jRlaWxLYfBo8y6D1W4UClYuDmkJ6Hmsb7oNuqokEAAK+AVYIzascVdBmaly0D1fqoqK7CzuGYHgyg==", + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/@scalar/types/-/types-0.12.1.tgz", + "integrity": "sha512-qbUGuoRdzMUEDZmXuES9uZYnOw54wx+UgxE6fOwpaHVTyaCC/jcZultbcTLMiFFmgRinSoZjm/EJtSVtNknD2Q==", "license": "MIT", "dependencies": { "@scalar/helpers": "0.8.0", @@ -5068,9 +5143,9 @@ } }, "node_modules/@scalar/validation": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@scalar/validation/-/validation-0.5.0.tgz", - "integrity": "sha512-48CS1B0C7im57RQCHhS0GKt+qDLxB34IX8rO91jZj9D+Y/OosQZG3Gy57gJdHqZoD7l0sPUZtF8tVYry3BxRtw==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@scalar/validation/-/validation-0.6.0.tgz", + "integrity": "sha512-tpmmG+/xRE2Kn9RpflU3AIyZv08v10+E1ZrJCx7z6+/91zHVxy0M73kC1LT4/8PbYNt85ywyC8+n+D99JdMcGA==", "license": "MIT", "engines": { "node": ">=20" @@ -5183,9 +5258,9 @@ } }, "node_modules/@sentry/cli": { - "version": "2.58.5", - "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.58.5.tgz", - "integrity": "sha512-tavJ7yGUZV+z3Ct2/ZB6mg339i08sAk6HDkgqmSRuQEu2iLS5sl9HIvuXfM6xjv8fwlgFOSy++WNABNAcGHUbg==", + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.58.6.tgz", + "integrity": "sha512-baBcNPLLfUi9WuL+Tpri9BFaAdvugZIKelC5X0tt0Zdy+K0K+PCVSrnNmwMWU/HyaF/SEv6b6UHnXIdqanBlcg==", "hasInstallScript": true, "license": "FSL-1.1-MIT", "dependencies": { @@ -5202,20 +5277,20 @@ "node": ">= 10" }, "optionalDependencies": { - "@sentry/cli-darwin": "2.58.5", - "@sentry/cli-linux-arm": "2.58.5", - "@sentry/cli-linux-arm64": "2.58.5", - "@sentry/cli-linux-i686": "2.58.5", - "@sentry/cli-linux-x64": "2.58.5", - "@sentry/cli-win32-arm64": "2.58.5", - "@sentry/cli-win32-i686": "2.58.5", - "@sentry/cli-win32-x64": "2.58.5" + "@sentry/cli-darwin": "2.58.6", + "@sentry/cli-linux-arm": "2.58.6", + "@sentry/cli-linux-arm64": "2.58.6", + "@sentry/cli-linux-i686": "2.58.6", + "@sentry/cli-linux-x64": "2.58.6", + "@sentry/cli-win32-arm64": "2.58.6", + "@sentry/cli-win32-i686": "2.58.6", + "@sentry/cli-win32-x64": "2.58.6" } }, "node_modules/@sentry/cli-darwin": { - "version": "2.58.5", - "resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.58.5.tgz", - "integrity": "sha512-lYrNzenZFJftfwSya7gwrHGxtE+Kob/e1sr9lmHMFOd4utDlmq0XFDllmdZAMf21fxcPRI1GL28ejZ3bId01fQ==", + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.58.6.tgz", + "integrity": "sha512-udAVvcyfNa0R+95GvPz/+43/N3TC0TYKdkQ7D7jhPSzbcMc7l2fxRNN5yB3UpCA5fWFnW4toeaqwDBhb/Wh3LA==", "license": "FSL-1.1-MIT", "optional": true, "os": [ @@ -5226,9 +5301,9 @@ } }, "node_modules/@sentry/cli-linux-arm": { - "version": "2.58.5", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.58.5.tgz", - "integrity": "sha512-KtHweSIomYL4WVDrBrYSYJricKAAzxUgX86kc6OnlikbyOhoK6Fy8Vs6vwd52P6dvWPjgrMpUYjW2M5pYXQDUw==", + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.58.6.tgz", + "integrity": "sha512-pD0LAt5PcUzAinBwvDqc66x9+2CabHEv486yP0gRjWO7SakbaxmfVq/EXd8VLq/Tzi39LAu422UYK1lpW3MILw==", "cpu": [ "arm" ], @@ -5244,9 +5319,9 @@ } }, "node_modules/@sentry/cli-linux-arm64": { - "version": "2.58.5", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.58.5.tgz", - "integrity": "sha512-/4gywFeBqRB6tR/iGMRAJ3HRqY6Z7Yp4l8ZCbl0TDLAfHNxu7schEw4tSnm2/Hh9eNMiOVy4z58uzAWlZXAYBQ==", + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.58.6.tgz", + "integrity": "sha512-q8mEcNNmeXMy5i+jWT30TVpH7LcP4HD21CD5XRSPAd/a912HF6EpK0ybf/1USO14WOhoXbAGi9txwaWabSe33g==", "cpu": [ "arm64" ], @@ -5262,9 +5337,9 @@ } }, "node_modules/@sentry/cli-linux-i686": { - "version": "2.58.5", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.58.5.tgz", - "integrity": "sha512-G7261dkmyxqlMdyvyP06b+RTIVzp1gZNgglj5UksxSouSUqRd/46W/2pQeOMPhloDYo9yLtCN2YFb3Mw4aUsWw==", + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.58.6.tgz", + "integrity": "sha512-q8vNJi1eOV/4vxAFWBsEwLHoSYapaZHIf4j76KJGJXFKTkEbsjCOOsKbwUIBTQQhRgV4DFWh3ryfsPS/que4Kg==", "cpu": [ "x86", "ia32" @@ -5281,9 +5356,9 @@ } }, "node_modules/@sentry/cli-linux-x64": { - "version": "2.58.5", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.58.5.tgz", - "integrity": "sha512-rP04494RSmt86xChkQ+ecBNRYSPbyXc4u0IA7R7N1pSLCyO74e5w5Al+LnAq35cMfVbZgz5Sm0iGLjyiUu4I1g==", + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.58.6.tgz", + "integrity": "sha512-DZu956Mhi3ZRjTBe1WdbGV46ldVbA8d2rgp/fh51GsI25zjBHah4wZnPTSzpc+YqxU6pJpg579B/r3jrIK530Q==", "cpu": [ "x64" ], @@ -5299,9 +5374,9 @@ } }, "node_modules/@sentry/cli-win32-arm64": { - "version": "2.58.5", - "resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.58.5.tgz", - "integrity": "sha512-AOJ2nCXlQL1KBaCzv38m3i2VmSHNurUpm7xVKd6yAHX+ZoVBI8VT0EgvwmtJR2TY2N2hNCC7UrgRmdUsQ152bA==", + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.58.6.tgz", + "integrity": "sha512-nj0Ff/kmAB73EPDhR8B4O9r+NUHK5GkPCkGWC+kXVemqAJWL5jcJ5KdxG0l/S0z6RoEoltID8/43/B+TaMlT7A==", "cpu": [ "arm64" ], @@ -5315,9 +5390,9 @@ } }, "node_modules/@sentry/cli-win32-i686": { - "version": "2.58.5", - "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.58.5.tgz", - "integrity": "sha512-EsuboLSOnlrN7MMPJ1eFvfMDm+BnzOaSWl8eYhNo8W/BIrmNgpRUdBwnWn9Q2UOjJj5ZopukmsiMYtU/D7ml9g==", + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.58.6.tgz", + "integrity": "sha512-WNZiDzPbgsEMQWq4avsQ391v/xWKJDIWWWo9GYl+N/w5qcYKkoDW7wQG7T9FasI6ENn68phChTOAPXXxbfAdOg==", "cpu": [ "x86", "ia32" @@ -5332,9 +5407,9 @@ } }, "node_modules/@sentry/cli-win32-x64": { - "version": "2.58.5", - "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.58.5.tgz", - "integrity": "sha512-IZf+XIMiQwj+5NzqbOQfywlOitmCV424Vtf9c+ep61AaVScUFD1TSrQbOcJJv5xGxhlxNOMNgMeZhdexdzrKZg==", + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.58.6.tgz", + "integrity": "sha512-R35WJ17oF4D2eqI1DR2sQQqr0fjRTt5xoP16WrTu91XM2lndRMFsnjh+/GttbxapLCBNlrjzia99MJ0PZHZpgA==", "cpu": [ "x64" ], @@ -5712,9 +5787,9 @@ "license": "MIT" }, "node_modules/@supabase/auth-js": { - "version": "2.105.4", - "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.105.4.tgz", - "integrity": "sha512-Ejfa37M5xoIwoxVebxRahnwubPo8g22qkXQ4p50+N9MIvU9UZoN+A8dwVPtczzGf8oV/YXN80ZPxK4aWXuSN/A==", + "version": "2.106.1", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.106.1.tgz", + "integrity": "sha512-7eyheXfAGwkB9bZewJPs+N3UYt6kra2JG6mIxNEgbkvcO15PLD1e75PTIUEYYl3zrifm3GrpShVl7QZxKrXO/w==", "license": "MIT", "dependencies": { "tslib": "2.8.1" @@ -5723,10 +5798,134 @@ "node": ">=20.0.0" } }, + "node_modules/@supabase/cli-darwin-arm64": { + "version": "2.101.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-darwin-arm64/-/cli-darwin-arm64-2.101.0.tgz", + "integrity": "sha512-SoWYzu2CIE+rADWsZH6H0YZYF8nV2YMelZLbjAqzahb5RRgFOTMWGhKCUj6g4KlNvocUnU19JObUJ7oCx/9aHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@supabase/cli-darwin-x64": { + "version": "2.101.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-darwin-x64/-/cli-darwin-x64-2.101.0.tgz", + "integrity": "sha512-rPES03BF9KYkirq3X1Yll+T+eORueRvC858Yp2+2aQMvB4qxi6k+WI+ilCQ7NGzOJ3G0jl/ZG/KsSjZIa4AJ6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@supabase/cli-linux-arm64": { + "version": "2.101.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-linux-arm64/-/cli-linux-arm64-2.101.0.tgz", + "integrity": "sha512-E+hN1GRIFEi+U1Jf4Omw/gjwpSRTpJ5jai58tQana15AaXt3GCoG7z2YzWVnzD/mvAubTqA7SLHsV+A2lcdTGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@supabase/cli-linux-arm64-musl": { + "version": "2.101.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.101.0.tgz", + "integrity": "sha512-B5JFFKKpJLLMwn8vWUB7j5euZPytiDOLo9WYXp0mshg45OaFKjRcDImSzBzx4k0W8MK2x75rFEURca90Ic60Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@supabase/cli-linux-x64": { + "version": "2.101.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-linux-x64/-/cli-linux-x64-2.101.0.tgz", + "integrity": "sha512-7iU9uA+8pbmW5uz+yGxU0fM83a+8SH/gtKmSSRZrOu9vIGcugg9gLbN9GlWkG5P7I7wEjnoVKb7Jzea6onSeQA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@supabase/cli-linux-x64-musl": { + "version": "2.101.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-linux-x64-musl/-/cli-linux-x64-musl-2.101.0.tgz", + "integrity": "sha512-pr9Ar1/aVaQVqjNSfwRgXHWJznQTIaxblGO+hKIl9k55Ajahiq8GConCwx6DjX1Wa9VW//5Dji+O0pkmFSfcHw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@supabase/cli-windows-arm64": { + "version": "2.101.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-windows-arm64/-/cli-windows-arm64-2.101.0.tgz", + "integrity": "sha512-mSnFDhTIAe9cr1iifv+DTQzpY/0WphywED2YT3yMGeu94jjxrXrreQkywxEaeSXTnc4ys1umtnWOB1CaBlYKag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@supabase/cli-windows-x64": { + "version": "2.101.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-windows-x64/-/cli-windows-x64-2.101.0.tgz", + "integrity": "sha512-xzZpybVWq62VF102abZr1EP3BbA2bYecwYm3G2dJk+6ua7nAIodu1fiGHAvBSR0BLGQmesNb6KCneawNL3XBsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@supabase/functions-js": { - "version": "2.105.4", - "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.105.4.tgz", - "integrity": "sha512-JVNKbBft3Qkja+WlGaE026AJ2AH9K0UTsxsfvEIHgd4zFrBor4BYRCrYFrv9IDsvVqkF72wKDsODJl5GY/C4tA==", + "version": "2.106.1", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.106.1.tgz", + "integrity": "sha512-XbOPnR2mW7jp/EcW447xmGwCa+/Wc00Hkw8t4tUIJjRsHQ4xAESsLKcyLRhRJjJoUnJVXUlC+w0wUxUCM7CG2A==", "license": "MIT", "dependencies": { "tslib": "2.8.1" @@ -5742,9 +5941,9 @@ "license": "MIT" }, "node_modules/@supabase/postgrest-js": { - "version": "2.105.4", - "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.105.4.tgz", - "integrity": "sha512-SppIyLo/kTwIlz1qpv2HN1EQqBg0GVktrDDFsXygYROha3MgVn4rT7p5EjFHFqXQm2rdRGb/BI7bc+jr10m91w==", + "version": "2.106.1", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.106.1.tgz", + "integrity": "sha512-Qbn6d2lqiqeaBX1Uko0e/hL90dtQGRN6CG2wMVQtJpRFstlVW45qmUTyTOsiB8dYUWu1fWYo4YzJuDbokGv3tQ==", "license": "MIT", "dependencies": { "tslib": "2.8.1" @@ -5754,9 +5953,9 @@ } }, "node_modules/@supabase/realtime-js": { - "version": "2.105.4", - "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.105.4.tgz", - "integrity": "sha512-6ov6c59+8D9h7q4M4Gy/uDJlC0Akxl9/714Y+6vJ+Sijuc16TS/p5DwhfRCLNcIhNiej1gEt+CQUwsjiPt4PxQ==", + "version": "2.106.1", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.106.1.tgz", + "integrity": "sha512-eQCYri5E8KsjpDgC7g28cOOS2britjUWdNSJluFMainqrMRepzjOnaxqXc3RoAz7H0dxmBrfLUNF6NGP8C+YaA==", "license": "MIT", "dependencies": { "@supabase/phoenix": "^0.4.2", @@ -5779,9 +5978,9 @@ } }, "node_modules/@supabase/storage-js": { - "version": "2.105.4", - "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.105.4.tgz", - "integrity": "sha512-Jx+pzMP1Whjof2PWHoVBUA75/p7PQE9CqKBzn1oXVyJDOggMLSH2OzVWwsXYaxEpdC1K/KltwmOX44nL3LHl9g==", + "version": "2.106.1", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.106.1.tgz", + "integrity": "sha512-HWcLIhqinhWKpOQ3WzglR2unjW0eh9J7yOu3IZrZNIEkraK4La/HDvTqndljGsNw0itPtyHhuKBxRoPG1VUARw==", "license": "MIT", "dependencies": { "iceberg-js": "^0.8.1", @@ -5792,16 +5991,16 @@ } }, "node_modules/@supabase/supabase-js": { - "version": "2.105.4", - "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.105.4.tgz", - "integrity": "sha512-cEnx+k49knU+qdIP7rXwR6fqEXPHZs+74xFK1R0S8MgQ7v9tbePVdGxvO03n3bPympMdJWVLadARBfU4TgNHCQ==", + "version": "2.106.1", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.106.1.tgz", + "integrity": "sha512-gP4HurGkGu7Z3xoOCjtAI17BKKp7jpsmwY0Ssbsks9XQRzJ7ZhK7LxfLdBSYgUdgZCQgjRK+Mr7+cl4Gxrk0Rw==", "license": "MIT", "dependencies": { - "@supabase/auth-js": "2.105.4", - "@supabase/functions-js": "2.105.4", - "@supabase/postgrest-js": "2.105.4", - "@supabase/realtime-js": "2.105.4", - "@supabase/storage-js": "2.105.4" + "@supabase/auth-js": "2.106.1", + "@supabase/functions-js": "2.106.1", + "@supabase/postgrest-js": "2.106.1", + "@supabase/realtime-js": "2.106.1", + "@supabase/storage-js": "2.106.1" }, "engines": { "node": ">=20.0.0" @@ -5949,6 +6148,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5966,6 +6168,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -5983,6 +6188,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -6000,6 +6208,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -6088,9 +6299,9 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.100.10", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.10.tgz", - "integrity": "sha512-8UR0yJR+GiQ40m3lPhUr0xbfAupe6GSQiksSBSa9SM2NjezFyxXCIA69/lz8cSoNKZLrw1/PktIyQBJcVeMi3w==", + "version": "5.100.13", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.13.tgz", + "integrity": "sha512-mlKVKMTzZWGTKAC1CKOgt7axAjJ921emkEvYIp27I/PdP1yEYL/BteLY8iK35gn8hoYeKB4mgJ/ve3lrDI6/Fw==", "license": "MIT", "funding": { "type": "github", @@ -6098,12 +6309,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.100.10", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.10.tgz", - "integrity": "sha512-FLaZf2RCrA/Zgp4aiu5tG3TyasTRO7aZ99skxQpr3Hg/zXOhu6yq5FZCYQ/tRaJtM9ylnoK8tFK7PolXQadv6Q==", + "version": "5.100.13", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.13.tgz", + "integrity": "sha512-HSBr8CycQEAoXsJR7KNDawBnINJEJ96Eme8oE0hCXjyodE2I97vg3IDzDJBDu18LsbzpVVJcKo80eqLfVCykxw==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.100.10" + "@tanstack/query-core": "5.100.13" }, "funding": { "type": "github", @@ -6114,12 +6325,12 @@ } }, "node_modules/@tanstack/react-virtual": { - "version": "3.13.24", - "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.24.tgz", - "integrity": "sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==", + "version": "3.13.25", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.25.tgz", + "integrity": "sha512-bmNoqMu6gcAW9JGrKVB0Q1tN1i5RONZF8r1fW0bbE4Oyf3DwEGnzzQJ2OW+Ozg1P4s8PyugkHg2ULZoFQN+cqw==", "license": "MIT", "dependencies": { - "@tanstack/virtual-core": "3.14.0" + "@tanstack/virtual-core": "3.15.0" }, "funding": { "type": "github", @@ -6131,9 +6342,9 @@ } }, "node_modules/@tanstack/virtual-core": { - "version": "3.14.0", - "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.14.0.tgz", - "integrity": "sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==", + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.15.0.tgz", + "integrity": "sha512-0AwPGx0I8QxPYjAxShT/+z+ZOe9u8mW5rsXvivCTjRfRmz9a43+3mRyi4wwlyoUqOC56q/jatKa0Bh9M99BEHQ==", "license": "MIT", "funding": { "type": "github", @@ -6372,28 +6583,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/eslint": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", - "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", - "license": "MIT", - "peer": true, - "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" - } - }, - "node_modules/@types/eslint-scope": { - "version": "3.7.7", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", - "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@types/eslint": "*", - "@types/estree": "*" - } - }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -6422,6 +6611,7 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, "license": "MIT" }, "node_modules/@types/json5": { @@ -6473,9 +6663,9 @@ } }, "node_modules/@types/node": { - "version": "25.8.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz", - "integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==", + "version": "25.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", + "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", "license": "MIT", "dependencies": { "undici-types": ">=7.24.0 <7.24.7" @@ -6509,9 +6699,10 @@ } }, "node_modules/@types/react": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", - "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "version": "19.2.15", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", + "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", + "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -6521,7 +6712,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "devOptional": true, + "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -6619,17 +6810,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz", - "integrity": "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.4.tgz", + "integrity": "sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.59.3", - "@typescript-eslint/type-utils": "8.59.3", - "@typescript-eslint/utils": "8.59.3", - "@typescript-eslint/visitor-keys": "8.59.3", + "@typescript-eslint/scope-manager": "8.59.4", + "@typescript-eslint/type-utils": "8.59.4", + "@typescript-eslint/utils": "8.59.4", + "@typescript-eslint/visitor-keys": "8.59.4", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -6642,7 +6833,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.59.3", + "@typescript-eslint/parser": "^8.59.4", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -6658,16 +6849,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.3.tgz", - "integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.4.tgz", + "integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.59.3", - "@typescript-eslint/types": "8.59.3", - "@typescript-eslint/typescript-estree": "8.59.3", - "@typescript-eslint/visitor-keys": "8.59.3", + "@typescript-eslint/scope-manager": "8.59.4", + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/typescript-estree": "8.59.4", + "@typescript-eslint/visitor-keys": "8.59.4", "debug": "^4.4.3" }, "engines": { @@ -6683,14 +6874,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.3.tgz", - "integrity": "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.4.tgz", + "integrity": "sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.59.3", - "@typescript-eslint/types": "^8.59.3", + "@typescript-eslint/tsconfig-utils": "^8.59.4", + "@typescript-eslint/types": "^8.59.4", "debug": "^4.4.3" }, "engines": { @@ -6705,14 +6896,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz", - "integrity": "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.4.tgz", + "integrity": "sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.3", - "@typescript-eslint/visitor-keys": "8.59.3" + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/visitor-keys": "8.59.4" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6723,9 +6914,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz", - "integrity": "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.4.tgz", + "integrity": "sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==", "dev": true, "license": "MIT", "engines": { @@ -6740,15 +6931,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.3.tgz", - "integrity": "sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.4.tgz", + "integrity": "sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.3", - "@typescript-eslint/typescript-estree": "8.59.3", - "@typescript-eslint/utils": "8.59.3", + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/typescript-estree": "8.59.4", + "@typescript-eslint/utils": "8.59.4", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -6765,9 +6956,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.3.tgz", - "integrity": "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.4.tgz", + "integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==", "dev": true, "license": "MIT", "engines": { @@ -6779,16 +6970,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz", - "integrity": "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.4.tgz", + "integrity": "sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.59.3", - "@typescript-eslint/tsconfig-utils": "8.59.3", - "@typescript-eslint/types": "8.59.3", - "@typescript-eslint/visitor-keys": "8.59.3", + "@typescript-eslint/project-service": "8.59.4", + "@typescript-eslint/tsconfig-utils": "8.59.4", + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/visitor-keys": "8.59.4", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -6807,9 +6998,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", - "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", "dev": true, "license": "ISC", "bin": { @@ -6820,16 +7011,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.3.tgz", - "integrity": "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.4.tgz", + "integrity": "sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.59.3", - "@typescript-eslint/types": "8.59.3", - "@typescript-eslint/typescript-estree": "8.59.3" + "@typescript-eslint/scope-manager": "8.59.4", + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/typescript-estree": "8.59.4" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6844,13 +7035,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz", - "integrity": "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.4.tgz", + "integrity": "sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/types": "8.59.4", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -6868,9 +7059,9 @@ "license": "ISC" }, "node_modules/@unrs/resolver-binding-android-arm-eabi": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", - "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", + "integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==", "cpu": [ "arm" ], @@ -6882,9 +7073,9 @@ ] }, "node_modules/@unrs/resolver-binding-android-arm64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", - "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz", + "integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==", "cpu": [ "arm64" ], @@ -6896,9 +7087,9 @@ ] }, "node_modules/@unrs/resolver-binding-darwin-arm64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", - "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz", + "integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==", "cpu": [ "arm64" ], @@ -6910,9 +7101,9 @@ ] }, "node_modules/@unrs/resolver-binding-darwin-x64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", - "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz", + "integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==", "cpu": [ "x64" ], @@ -6924,9 +7115,9 @@ ] }, "node_modules/@unrs/resolver-binding-freebsd-x64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", - "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz", + "integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==", "cpu": [ "x64" ], @@ -6938,9 +7129,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", - "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz", + "integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==", "cpu": [ "arm" ], @@ -6952,9 +7143,9 @@ ] }, "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", - "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz", + "integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==", "cpu": [ "arm" ], @@ -6966,13 +7157,16 @@ ] }, "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", - "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz", + "integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -6980,83 +7174,135 @@ ] }, "node_modules/@unrs/resolver-binding-linux-arm64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", - "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz", + "integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ "linux" ] }, - "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", - "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "node_modules/@unrs/resolver-binding-linux-loong64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz", + "integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==", "cpu": [ - "ppc64" + "loong64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ "linux" ] }, - "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", - "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "node_modules/@unrs/resolver-binding-linux-loong64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz", + "integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==", "cpu": [ - "riscv64" + "loong64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ "linux" ] }, - "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", - "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz", + "integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==", "cpu": [ - "riscv64" + "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ "linux" ] }, - "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", - "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz", + "integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==", "cpu": [ - "s390x" + "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ "linux" ] }, - "node_modules/@unrs/resolver-binding-linux-x64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", - "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz", + "integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz", + "integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz", + "integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -7064,23 +7310,40 @@ ] }, "node_modules/@unrs/resolver-binding-linux-x64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", - "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz", + "integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ "linux" ] }, + "node_modules/@unrs/resolver-binding-openharmony-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz", + "integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, "node_modules/@unrs/resolver-binding-wasm32-wasi": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", - "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz", + "integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==", "cpu": [ "wasm32" ], @@ -7088,16 +7351,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@napi-rs/wasm-runtime": "^0.2.11" + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", - "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", + "integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==", "cpu": [ "arm64" ], @@ -7109,9 +7374,9 @@ ] }, "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", - "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz", + "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==", "cpu": [ "ia32" ], @@ -7123,9 +7388,9 @@ ] }, "node_modules/@unrs/resolver-binding-win32-x64-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", - "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", + "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==", "cpu": [ "x64" ], @@ -7196,14 +7461,14 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.6.tgz", - "integrity": "sha512-36l628fQ/9a/8ihy97eOtEnvWQEdqULQOJtcaxtoNq0G1w3Mxd4szSahOaMM9/NGyZ+hyKcMtIW/WIxq0XQViQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.7.tgz", + "integrity": "sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.6", + "@vitest/utils": "4.1.7", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -7217,8 +7482,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.6", - "vitest": "4.1.6" + "@vitest/browser": "4.1.7", + "vitest": "4.1.7" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -7227,16 +7492,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz", - "integrity": "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", + "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.6", - "@vitest/utils": "4.1.6", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -7245,13 +7510,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz", - "integrity": "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", + "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.6", + "@vitest/spy": "4.1.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -7282,9 +7547,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.6.tgz", - "integrity": "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", + "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", "dev": true, "license": "MIT", "dependencies": { @@ -7295,13 +7560,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.6.tgz", - "integrity": "sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", + "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.6", + "@vitest/utils": "4.1.7", "pathe": "^2.0.3" }, "funding": { @@ -7309,14 +7574,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.6.tgz", - "integrity": "sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", + "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.6", - "@vitest/utils": "4.1.6", + "@vitest/pretty-format": "4.1.7", + "@vitest/utils": "4.1.7", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -7325,9 +7590,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.6.tgz", - "integrity": "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", + "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", "dev": true, "license": "MIT", "funding": { @@ -7335,13 +7600,13 @@ } }, "node_modules/@vitest/ui": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.6.tgz", - "integrity": "sha512-wiu5em68DfGv/2HFvI1Njr7JI2CHcBlQvereSzVG8my53PRxjTNOCsD9VOkRKrsJBDHmyuXvosxWZw7T91a2mw==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.7.tgz", + "integrity": "sha512-TP6utB2yX6rsJNVRo2qAlsi48i1YwFTrLV2tnTtWqJaYX7m4lRCCLirZBjU6xC5m0RsPHr+L2+N+eIPhgEzFfw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.6", + "@vitest/utils": "4.1.7", "fflate": "^0.8.2", "flatted": "^3.4.2", "pathe": "^2.0.3", @@ -7353,17 +7618,17 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "4.1.6" + "vitest": "4.1.7" } }, "node_modules/@vitest/utils": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.6.tgz", - "integrity": "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", + "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.6", + "@vitest/pretty-format": "4.1.7", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -7371,181 +7636,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@webassemblyjs/ast": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", - "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/helper-numbers": "1.13.2", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2" - } - }, - "node_modules/@webassemblyjs/floating-point-hex-parser": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", - "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", - "license": "MIT", - "peer": true - }, - "node_modules/@webassemblyjs/helper-api-error": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", - "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", - "license": "MIT", - "peer": true - }, - "node_modules/@webassemblyjs/helper-buffer": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", - "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", - "license": "MIT", - "peer": true - }, - "node_modules/@webassemblyjs/helper-numbers": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", - "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/floating-point-hex-parser": "1.13.2", - "@webassemblyjs/helper-api-error": "1.13.2", - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@webassemblyjs/helper-wasm-bytecode": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", - "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", - "license": "MIT", - "peer": true - }, - "node_modules/@webassemblyjs/helper-wasm-section": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", - "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/wasm-gen": "1.14.1" - } - }, - "node_modules/@webassemblyjs/ieee754": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", - "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@xtuc/ieee754": "^1.2.0" - } - }, - "node_modules/@webassemblyjs/leb128": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", - "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@webassemblyjs/utf8": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", - "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", - "license": "MIT", - "peer": true - }, - "node_modules/@webassemblyjs/wasm-edit": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", - "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/helper-wasm-section": "1.14.1", - "@webassemblyjs/wasm-gen": "1.14.1", - "@webassemblyjs/wasm-opt": "1.14.1", - "@webassemblyjs/wasm-parser": "1.14.1", - "@webassemblyjs/wast-printer": "1.14.1" - } - }, - "node_modules/@webassemblyjs/wasm-gen": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", - "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/ieee754": "1.13.2", - "@webassemblyjs/leb128": "1.13.2", - "@webassemblyjs/utf8": "1.13.2" - } - }, - "node_modules/@webassemblyjs/wasm-opt": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", - "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/wasm-gen": "1.14.1", - "@webassemblyjs/wasm-parser": "1.14.1" - } - }, - "node_modules/@webassemblyjs/wasm-parser": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", - "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-api-error": "1.13.2", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/ieee754": "1.13.2", - "@webassemblyjs/leb128": "1.13.2", - "@webassemblyjs/utf8": "1.13.2" - } - }, - "node_modules/@webassemblyjs/wast-printer": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", - "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@xtuc/ieee754": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", - "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", - "license": "BSD-3-Clause", - "peer": true - }, - "node_modules/@xtuc/long": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", - "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", - "license": "Apache-2.0", - "peer": true - }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -7593,19 +7683,6 @@ "acorn": "^8" } }, - "node_modules/acorn-import-phases": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", - "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10.13.0" - }, - "peerDependencies": { - "acorn": "^8.14.0" - } - }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -8113,9 +8190,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.30", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.30.tgz", - "integrity": "sha512-xjOFN16Ha1+Rz4nFYKqHU/LSB+gx/Vi3yQLX7r7sAW+Wa+8hhF2h4pvqTrTMc8+WcDBEunnUurr46Jvv0jk3Vg==", + "version": "2.10.32", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", + "integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -8143,23 +8220,6 @@ "node": "*" } }, - "node_modules/bin-links": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/bin-links/-/bin-links-6.0.0.tgz", - "integrity": "sha512-X4CiKlcV2GjnCMwnKAfbVWpHa++65th9TuzAEYtZoATiOE2DQKhSp4CJlyLoTqdhBKlXjpXjCTYPNNFS33Fi6w==", - "dev": true, - "license": "ISC", - "dependencies": { - "cmd-shim": "^8.0.0", - "npm-normalize-package-bin": "^5.0.0", - "proc-log": "^6.0.0", - "read-cmd-shim": "^6.0.0", - "write-file-atomic": "^7.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, "node_modules/brace-expansion": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", @@ -8224,13 +8284,6 @@ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "license": "MIT", - "peer": true - }, "node_modules/builtin-modules": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", @@ -8424,26 +8477,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/chrome-trace-event": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", - "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.0" - } - }, "node_modules/citty": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz", @@ -8594,16 +8627,6 @@ "node": ">=6" } }, - "node_modules/cmd-shim": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/cmd-shim/-/cmd-shim-8.0.0.tgz", - "integrity": "sha512-Jk/BK6NCapZ58BKUxlSI+ouKRbjH1NLZCgJkYoab+vEHUY3f6OzpNBN9u7HFSv9J6TRDGs4PLOHezoKGaFRSCA==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -8716,9 +8739,9 @@ "license": "MIT" }, "node_modules/conf/node_modules/semver": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", - "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -8802,6 +8825,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, "license": "MIT" }, "node_modules/d3-array": { @@ -9020,9 +9044,9 @@ } }, "node_modules/date-fns": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", - "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.2.1.tgz", + "integrity": "sha512-37RhSdxaG1suen6VDCza6rNrQfooyQh57HFVPwQGEq2QWliVLzPQZ8Oa017weOu+HZCnzI7N3Pf/wyoBKfEqrA==", "license": "MIT", "funding": { "type": "github", @@ -9355,9 +9379,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.357", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.357.tgz", - "integrity": "sha512-NHlTIQDK8fmVwHwuIzmXYEJ1Ewq3D9wDNc0cWXxDGysP6Pb21giwGNkxiTifyKy/4SoPuN5l6GLP1W9Sv7zB2g==", + "version": "1.5.361", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.361.tgz", + "integrity": "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==", "license": "ISC" }, "node_modules/emoji-regex": { @@ -9378,9 +9402,9 @@ } }, "node_modules/engine.io": { - "version": "6.6.7", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.7.tgz", - "integrity": "sha512-DgOngfDKM2EviOH3Mr9m7ks1q8roetLy/IMmYthAYzbpInMbYc/GS+fWFA3rl1gvwKVsQrVV61fo5emD1y3OJQ==", + "version": "6.6.8", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.8.tgz", + "integrity": "sha512-2agL3ueZhqxoVrfmntO8yuVj+uNSlIOnhykYHk3Cq0ShYPdUjjUiSJrQvXjq01I9jAuI0Zl2YO8Evv5Mqytm5g==", "license": "MIT", "dependencies": { "@types/cors": "^2.8.12", @@ -9392,7 +9416,7 @@ "cors": "~2.8.5", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", - "ws": "~8.18.3" + "ws": "~8.20.1" }, "engines": { "node": ">=10.2.0" @@ -9417,9 +9441,9 @@ } }, "node_modules/engine.io/node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -9438,9 +9462,10 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.21.3", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.3.tgz", - "integrity": "sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q==", + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.0.tgz", + "integrity": "sha512-xYcDWrpELkFzz9SpZ3PlI6Eu6eD93Yf0WLDRxikGhWJ3MAir2SNZTIVCVZqZ/NUyx8AdMc2gT9C0gPiw18kG+A==", + "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -9606,12 +9631,13 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, "license": "MIT" }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -10101,9 +10127,9 @@ } }, "node_modules/eslint-plugin-sonarjs/node_modules/semver": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", - "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", "dev": true, "license": "ISC", "bin": { @@ -10220,6 +10246,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" @@ -10232,6 +10259,7 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=4.0" @@ -10279,16 +10307,6 @@ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.8.x" - } - }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -10488,6 +10506,26 @@ "node": "^12.20 || >= 14.13" } }, + "node_modules/fetch-blob/node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, "node_modules/fflate": { "version": "0.8.3", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", @@ -10652,13 +10690,13 @@ "license": "MIT" }, "node_modules/framer-motion": { - "version": "12.38.0", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz", - "integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==", + "version": "12.40.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.40.0.tgz", + "integrity": "sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==", "license": "MIT", "dependencies": { - "motion-dom": "^12.38.0", - "motion-utils": "^12.36.0", + "motion-dom": "^12.40.0", + "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { @@ -10997,13 +11035,6 @@ "node": ">=10.13.0" } }, - "node_modules/glob-to-regexp": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "license": "BSD-2-Clause", - "peer": true - }, "node_modules/globals": { "version": "17.6.0", "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", @@ -11182,9 +11213,9 @@ } }, "node_modules/googleapis": { - "version": "171.4.0", - "resolved": "https://registry.npmjs.org/googleapis/-/googleapis-171.4.0.tgz", - "integrity": "sha512-xybFL2SmmUgIifgsbsRQYRdNrSAYwxWZDmkZTGjUIaRnX5jPqR8el/cEvo6rCqh7iaZx6MfEPS/lrDgZ0bymkg==", + "version": "172.0.0", + "resolved": "https://registry.npmjs.org/googleapis/-/googleapis-172.0.0.tgz", + "integrity": "sha512-cF1m/nwfu9JP+JtR6j2nkQKnhu0z45eUVsWCVeooU8EIvBiPQqbfgAuJsTggCYEpbWJNwq2WS+aigaBC/sriIw==", "license": "Apache-2.0", "dependencies": { "google-auth-library": "^10.2.0", @@ -11280,6 +11311,7 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, "license": "ISC" }, "node_modules/gtoken": { @@ -11331,6 +11363,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -11854,9 +11887,9 @@ } }, "node_modules/is-bun-module/node_modules/semver": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", - "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", "dev": true, "license": "ISC", "bin": { @@ -12355,37 +12388,6 @@ "node": ">= 0.4" } }, - "node_modules/jest-worker": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", - "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@types/node": "*", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - }, - "engines": { - "node": ">= 10.13.0" - } - }, - "node_modules/jest-worker/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "license": "MIT", - "peer": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -12558,9 +12560,9 @@ } }, "node_modules/jsonwebtoken/node_modules/semver": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", - "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -12867,6 +12869,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -12888,6 +12893,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -12909,6 +12917,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -12930,6 +12941,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -13032,20 +13046,6 @@ "node": ">=22.13.0" } }, - "node_modules/loader-runner": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz", - "integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.11.5" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -13268,9 +13268,9 @@ } }, "node_modules/lru-cache": { - "version": "11.4.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.4.0.tgz", - "integrity": "sha512-W+R+kFL4HgVxONq2bhXPi3bGpzGe/yEhVOp233qw9wCRtgncJ15P3bC+e4zZMu4Cq7d+WAJjXGW0uUkifhcatA==", + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", + "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -13361,9 +13361,9 @@ } }, "node_modules/make-dir/node_modules/semver": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", - "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", "dev": true, "license": "ISC", "bin": { @@ -13553,13 +13553,6 @@ "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", "license": "CC0-1.0" }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "license": "MIT", - "peer": true - }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -14128,19 +14121,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/minizlib": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", - "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.1.2" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/module-details-from-path": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", @@ -14148,18 +14128,18 @@ "license": "MIT" }, "node_modules/motion-dom": { - "version": "12.38.0", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz", - "integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==", + "version": "12.40.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.40.0.tgz", + "integrity": "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==", "license": "MIT", "dependencies": { - "motion-utils": "^12.36.0" + "motion-utils": "^12.39.0" } }, "node_modules/motion-utils": { - "version": "12.36.0", - "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz", - "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==", + "version": "12.39.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz", + "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==", "license": "MIT" }, "node_modules/mrmime": { @@ -14228,13 +14208,6 @@ "node": ">= 0.6" } }, - "node_modules/neo-async": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "license": "MIT", - "peer": true - }, "node_modules/next": { "version": "16.2.6", "resolved": "https://registry.npmjs.org/next/-/next-16.2.6.tgz", @@ -14307,9 +14280,9 @@ } }, "node_modules/node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-2.0.2.tgz", + "integrity": "sha512-Qf9vHK9c5MGgUXj8SnucCIS4oEPuUstjRaMplLGeZpbWMfNV1rvEcXuwoXfN51dUfD1b4muPHPQtCx/5Dj/QAA==", "deprecated": "Use your platform's native DOMException instead", "funding": [ { @@ -14323,7 +14296,7 @@ ], "license": "MIT", "engines": { - "node": ">=10.5.0" + "node": ">=16" } }, "node_modules/node-exports-info": { @@ -14388,10 +14361,13 @@ } }, "node_modules/node-releases": { - "version": "2.0.44", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz", - "integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==", - "license": "MIT" + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", + "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/normalize-path": { "version": "3.0.0", @@ -14402,16 +14378,6 @@ "node": ">=0.10.0" } }, - "node_modules/npm-normalize-package-bin": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-5.0.0.tgz", - "integrity": "sha512-CJi3OS4JLsNMmr2u07OJlhcrPxCeOeP/4xq67aWNai6TNWWbTrlNDgl8NcFKVlcBKp18GPj+EzbNIgrBfZhsag==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, "node_modules/nprogress": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/nprogress/-/nprogress-0.2.0.tgz", @@ -14842,9 +14808,9 @@ } }, "node_modules/pg-protocol": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", - "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz", + "integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==", "license": "MIT" }, "node_modules/pg-types": { @@ -14894,7 +14860,7 @@ "version": "1.60.0", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "playwright-core": "1.60.0" @@ -14913,7 +14879,7 @@ "version": "1.60.0", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" @@ -14939,9 +14905,9 @@ "license": "MIT-0" }, "node_modules/postcss": { - "version": "8.5.14", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", - "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "funding": [ { "type": "opencollective", @@ -14958,7 +14924,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -15104,16 +15070,6 @@ "node": ">=6" } }, - "node_modules/proc-log": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", - "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -15177,9 +15133,9 @@ } }, "node_modules/protobufjs": { - "version": "7.5.9", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.9.tgz", - "integrity": "sha512-Od4muIm3HW1AouyHF5lONOf1FWo3hY1NbFDoy191X9GzhpgW1clCoaFjfVs2rKJNFYpTNJbje4cbAIDBZJ63ZA==", + "version": "7.6.1", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.1.tgz", + "integrity": "sha512-4K0myLaWL5EteuSAro91EGFgcfVgxb64Jx+7oDAY6GOkXD4M69yuSEljNcInGVCA5sOPxmZ/EqDLj2x0Q0+Ygg==", "hasInstallScript": true, "license": "BSD-3-Clause", "optional": true, @@ -15187,7 +15143,7 @@ "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", - "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.2", @@ -15195,7 +15151,7 @@ "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", - "long": "^5.0.0" + "long": "^5.3.2" }, "engines": { "node": ">=12.0.0" @@ -15301,9 +15257,9 @@ } }, "node_modules/react-email": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/react-email/-/react-email-6.1.4.tgz", - "integrity": "sha512-UKCfry4W7zkAWoJX1ngaWgPrUazOebxI8IYrO8TBEqgFmmz97VqZ84ell2x36Fdvtzd/UI5e4ZOywlsXeydwgQ==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/react-email/-/react-email-6.3.1.tgz", + "integrity": "sha512-mmd7pMAIdoGS28GEmudWDvUXVugOGRQiCfL8zMNcvg02iuq8AGYFxVPJIY1Yof+0ftHLsHdOEefheZkfDXqhNQ==", "license": "MIT", "dependencies": { "@babel/parser": "7.27.0", @@ -15581,16 +15537,6 @@ "react-dom": ">= 16.13.1" } }, - "node_modules/read-cmd-shim": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/read-cmd-shim/-/read-cmd-shim-6.0.0.tgz", - "integrity": "sha512-1zM5HuOfagXCBWMN83fuFI/x+T/UhZ7k+KIzhrHXcQoeX5+7gmaDYjELQHmmzIodumBHeByBJT4QYS7ufAgs7A==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -15976,13 +15922,13 @@ } }, "node_modules/rolldown": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", - "integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", + "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.130.0", + "@oxc-project/types": "=0.132.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -15992,21 +15938,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.1", - "@rolldown/binding-darwin-arm64": "1.0.1", - "@rolldown/binding-darwin-x64": "1.0.1", - "@rolldown/binding-freebsd-x64": "1.0.1", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.1", - "@rolldown/binding-linux-arm64-gnu": "1.0.1", - "@rolldown/binding-linux-arm64-musl": "1.0.1", - "@rolldown/binding-linux-ppc64-gnu": "1.0.1", - "@rolldown/binding-linux-s390x-gnu": "1.0.1", - "@rolldown/binding-linux-x64-gnu": "1.0.1", - "@rolldown/binding-linux-x64-musl": "1.0.1", - "@rolldown/binding-openharmony-arm64": "1.0.1", - "@rolldown/binding-wasm32-wasi": "1.0.1", - "@rolldown/binding-win32-arm64-msvc": "1.0.1", - "@rolldown/binding-win32-x64-msvc": "1.0.1" + "@rolldown/binding-android-arm64": "1.0.2", + "@rolldown/binding-darwin-arm64": "1.0.2", + "@rolldown/binding-darwin-x64": "1.0.2", + "@rolldown/binding-freebsd-x64": "1.0.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", + "@rolldown/binding-linux-arm64-gnu": "1.0.2", + "@rolldown/binding-linux-arm64-musl": "1.0.2", + "@rolldown/binding-linux-ppc64-gnu": "1.0.2", + "@rolldown/binding-linux-s390x-gnu": "1.0.2", + "@rolldown/binding-linux-x64-gnu": "1.0.2", + "@rolldown/binding-linux-x64-musl": "1.0.2", + "@rolldown/binding-openharmony-arm64": "1.0.2", + "@rolldown/binding-wasm32-wasi": "1.0.2", + "@rolldown/binding-win32-arm64-msvc": "1.0.2", + "@rolldown/binding-win32-x64-msvc": "1.0.2" } }, "node_modules/rollup": { @@ -16202,81 +16148,6 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, - "node_modules/schema-utils": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", - "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/schema-utils/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "license": "MIT", - "peer": true, - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/schema-utils/node_modules/ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", - "license": "MIT", - "peer": true, - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/schema-utils/node_modules/ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "license": "MIT", - "peer": true, - "dependencies": { - "fast-deep-equal": "^3.1.3" - }, - "peerDependencies": { - "ajv": "^8.8.2" - } - }, - "node_modules/schema-utils/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT", - "peer": true - }, "node_modules/scslre": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/scslre/-/scslre-0.3.0.tgz", @@ -16432,9 +16303,9 @@ } }, "node_modules/sharp/node_modules/semver": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", - "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", "license": "ISC", "optional": true, "bin": { @@ -16629,19 +16500,19 @@ } }, "node_modules/socket.io-adapter": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz", - "integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==", + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.7.tgz", + "integrity": "sha512-e0LyK91f3cUxTmv95/KzoLg47+zF+s/sbxRGDNsyG4dmIP8ZSX8ax6byOxfJXeNNtS/8AZlfD+uP7gBeR7DLlg==", "license": "MIT", "dependencies": { "debug": "~4.4.1", - "ws": "~8.18.3" + "ws": "~8.20.1" } }, "node_modules/socket.io-adapter/node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -16700,17 +16571,6 @@ "node": ">=0.10.0" } }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "license": "MIT", - "peer": true, - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, "node_modules/space-separated-tokens": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", @@ -17109,66 +16969,23 @@ } }, "node_modules/supabase": { - "version": "2.98.2", - "resolved": "https://registry.npmjs.org/supabase/-/supabase-2.98.2.tgz", - "integrity": "sha512-COSz57JyuUGbj75GSGM5mmyz/behBiYSiJ4A9qJVVC/vNp9bYS+9RCTXBtEt8kgqDDYWZsOmzk+mPbIBdr9bPg==", + "version": "2.101.0", + "resolved": "https://registry.npmjs.org/supabase/-/supabase-2.101.0.tgz", + "integrity": "sha512-9wqlvXGeI+VjOMUKOGEpcZzTJGLia6IOv8LWN3W2u8F6Xiv4ZqAuTuXCp3MWfkhV5x9KQaBtN5d6IiLEMWzkJA==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "dependencies": { - "bin-links": "^6.0.0", - "https-proxy-agent": "^9.0.0", - "node-fetch": "^3.3.2", - "tar": "7.5.13" - }, "bin": { - "supabase": "bin/supabase" - }, - "engines": { - "npm": ">=8" - } - }, - "node_modules/supabase/node_modules/agent-base": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", - "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 20" - } - }, - "node_modules/supabase/node_modules/https-proxy-agent": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.0.0.tgz", - "integrity": "sha512-/MVmHp58WkOypgFhCLk4fzpPcFQvTJ/e6LBI7irpIO2HfxUbpmYoHF+KzipzJpxxzJu7aJNWQ0xojJ/dzV2G5g==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "9.0.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/supabase/node_modules/node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "dev": true, - "license": "MIT", - "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "supabase": "dist/supabase.js" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" + "optionalDependencies": { + "@supabase/cli-darwin-arm64": "2.101.0", + "@supabase/cli-darwin-x64": "2.101.0", + "@supabase/cli-linux-arm64": "2.101.0", + "@supabase/cli-linux-arm64-musl": "2.101.0", + "@supabase/cli-linux-x64": "2.101.0", + "@supabase/cli-linux-x64-musl": "2.101.0", + "@supabase/cli-windows-arm64": "2.101.0", + "@supabase/cli-windows-x64": "2.101.0" } }, "node_modules/supports-color": { @@ -17245,6 +17062,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -17254,23 +17072,6 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/tar": { - "version": "7.5.15", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz", - "integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.1.0", - "yallist": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/teeny-request": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz", @@ -17288,93 +17089,6 @@ "node": ">=14" } }, - "node_modules/terser": { - "version": "5.47.1", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.47.1.tgz", - "integrity": "sha512-tPbLXTI6ohPASb/1YViL428oEHu6/qv1OxqYnfaonVCFHqx4+wCd95pHrQWsL5X4pl90CTyW9piSAsS2L0VoMw==", - "license": "BSD-2-Clause", - "peer": true, - "dependencies": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.15.0", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "bin": { - "terser": "bin/terser" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/terser-webpack-plugin": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.6.0.tgz", - "integrity": "sha512-Eum+5ajkaOhf5KbM26osvv21kLD7BaGqQ1UA4Ami4arYwylmGUQTgHFpHDdmJod1q4QXa66p0to/FBKID+J1vA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.25", - "jest-worker": "^27.4.5", - "schema-utils": "^4.3.0", - "terser": "^5.31.1" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.1.0" - }, - "peerDependenciesMeta": { - "@minify-html/node": { - "optional": true - }, - "@swc/core": { - "optional": true - }, - "@swc/css": { - "optional": true - }, - "@swc/html": { - "optional": true - }, - "clean-css": { - "optional": true - }, - "cssnano": { - "optional": true - }, - "csso": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "html-minifier-terser": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "postcss": { - "optional": true - }, - "uglify-js": { - "optional": true - } - } - }, - "node_modules/terser/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "license": "MIT", - "peer": true - }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -17677,7 +17391,7 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -17688,16 +17402,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.3.tgz", - "integrity": "sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==", + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.4.tgz", + "integrity": "sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.59.3", - "@typescript-eslint/parser": "8.59.3", - "@typescript-eslint/typescript-estree": "8.59.3", - "@typescript-eslint/utils": "8.59.3" + "@typescript-eslint/eslint-plugin": "8.59.4", + "@typescript-eslint/parser": "8.59.4", + "@typescript-eslint/typescript-estree": "8.59.4", + "@typescript-eslint/utils": "8.59.4" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -17852,38 +17566,41 @@ } }, "node_modules/unrs-resolver": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", - "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", + "integrity": "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==", "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { - "napi-postinstall": "^0.3.0" + "napi-postinstall": "^0.3.4" }, "funding": { "url": "https://opencollective.com/unrs-resolver" }, "optionalDependencies": { - "@unrs/resolver-binding-android-arm-eabi": "1.11.1", - "@unrs/resolver-binding-android-arm64": "1.11.1", - "@unrs/resolver-binding-darwin-arm64": "1.11.1", - "@unrs/resolver-binding-darwin-x64": "1.11.1", - "@unrs/resolver-binding-freebsd-x64": "1.11.1", - "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", - "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", - "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", - "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", - "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", - "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-x64-musl": "1.11.1", - "@unrs/resolver-binding-wasm32-wasi": "1.11.1", - "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", - "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", - "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + "@unrs/resolver-binding-android-arm-eabi": "1.12.2", + "@unrs/resolver-binding-android-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-x64": "1.12.2", + "@unrs/resolver-binding-freebsd-x64": "1.12.2", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", + "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", + "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-musl": "1.12.2", + "@unrs/resolver-binding-openharmony-arm64": "1.12.2", + "@unrs/resolver-binding-wasm32-wasi": "1.12.2", + "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", + "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", + "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" } }, "node_modules/update-browserslist-db": { @@ -17992,16 +17709,16 @@ "optional": true }, "node_modules/uuid": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", - "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", + "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], "license": "MIT", "bin": { - "uuid": "dist/esm/bin/uuid" + "uuid": "dist-node/bin/uuid" } }, "node_modules/vary": { @@ -18064,16 +17781,16 @@ } }, "node_modules/vite": { - "version": "8.0.13", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz", - "integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==", + "version": "8.0.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", + "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", - "postcss": "^8.5.14", - "rolldown": "1.0.1", + "postcss": "^8.5.15", + "rolldown": "1.0.2", "tinyglobby": "^0.2.16" }, "bin": { @@ -18157,19 +17874,19 @@ } }, "node_modules/vitest": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz", - "integrity": "sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", + "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.6", - "@vitest/mocker": "4.1.6", - "@vitest/pretty-format": "4.1.6", - "@vitest/runner": "4.1.6", - "@vitest/snapshot": "4.1.6", - "@vitest/spy": "4.1.6", - "@vitest/utils": "4.1.6", + "@vitest/expect": "4.1.7", + "@vitest/mocker": "4.1.7", + "@vitest/pretty-format": "4.1.7", + "@vitest/runner": "4.1.7", + "@vitest/snapshot": "4.1.7", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -18197,12 +17914,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.6", - "@vitest/browser-preview": "4.1.6", - "@vitest/browser-webdriverio": "4.1.6", - "@vitest/coverage-istanbul": "4.1.6", - "@vitest/coverage-v8": "4.1.6", - "@vitest/ui": "4.1.6", + "@vitest/browser-playwright": "4.1.7", + "@vitest/browser-preview": "4.1.7", + "@vitest/browser-webdriverio": "4.1.7", + "@vitest/coverage-istanbul": "4.1.7", + "@vitest/coverage-v8": "4.1.7", + "@vitest/ui": "4.1.7", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -18259,20 +17976,6 @@ "node": ">=18" } }, - "node_modules/watchpack": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", - "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", - "license": "MIT", - "peer": true, - "dependencies": { - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.1.2" - }, - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -18292,98 +17995,6 @@ "node": ">=20" } }, - "node_modules/webpack": { - "version": "5.106.2", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.106.2.tgz", - "integrity": "sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@types/eslint-scope": "^3.7.7", - "@types/estree": "^1.0.8", - "@types/json-schema": "^7.0.15", - "@webassemblyjs/ast": "^1.14.1", - "@webassemblyjs/wasm-edit": "^1.14.1", - "@webassemblyjs/wasm-parser": "^1.14.1", - "acorn": "^8.16.0", - "acorn-import-phases": "^1.0.3", - "browserslist": "^4.28.1", - "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.20.0", - "es-module-lexer": "^2.0.0", - "eslint-scope": "5.1.1", - "events": "^3.2.0", - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.2.11", - "loader-runner": "^4.3.1", - "mime-db": "^1.54.0", - "neo-async": "^2.6.2", - "schema-utils": "^4.3.3", - "tapable": "^2.3.0", - "terser-webpack-plugin": "^5.3.17", - "watchpack": "^2.5.1", - "webpack-sources": "^3.3.4" - }, - "bin": { - "webpack": "bin/webpack.js" - }, - "engines": { - "node": ">=10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependenciesMeta": { - "webpack-cli": { - "optional": true - } - } - }, - "node_modules/webpack-sources": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.4.1.tgz", - "integrity": "sha512-eACpxRN02yaawnt+uUNIF7Qje6A9zArxBbcAJjK1PK3S9Ycg5jIuJ8pW4q8EMnwNZCEGltcjkRx1QzOxOkKD8A==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/webpack/node_modules/eslint-scope": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", - "license": "BSD-2-Clause", - "peer": true, - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/webpack/node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "license": "BSD-2-Clause", - "peer": true, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/webpack/node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/websocket-driver": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", @@ -18607,23 +18218,10 @@ "license": "ISC", "optional": true }, - "node_modules/write-file-atomic": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-7.0.1.tgz", - "integrity": "sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg==", - "dev": true, - "license": "ISC", - "dependencies": { - "signal-exit": "^4.0.1" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, "node_modules/ws": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "dev": true, "license": "MIT", "engines": { @@ -18695,14 +18293,10 @@ } }, "node_modules/yallist": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", - "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" }, "node_modules/yaml": { "version": "2.9.0", diff --git a/package.json b/package.json index 795303c9..b386f64b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ghostclass", - "version": "4.3.6", + "version": "4.3.7", "private": true, "engines": { "node": ">=22.12.0", @@ -46,7 +46,7 @@ "flatted": "^3.4.2", "@tootallnate/once": "^3.0.1", "postcss": "^8.5.14", - "uuid": "^11.0.5" + "uuid": "^14.0.0" }, "dependencies": { "@hookform/resolvers": "^5.2.2", @@ -70,7 +70,7 @@ "@serwist/next": "^9.5.6", "@supabase/ssr": "^0.10.2", "@supabase/supabase-js": "^2.97.0", - "@tanstack/react-query": "^5.90.21", + "@tanstack/react-query": "^5.100.13", "@tanstack/react-virtual": "^3.13.21", "@upstash/ratelimit": "^2.0.8", "@upstash/redis": "^1.36.2", @@ -80,7 +80,7 @@ "date-fns": "^4.1.0", "firebase-admin": "^13.8.0", "framer-motion": "^12.34.3", - "googleapis": "^171.4.0", + "googleapis": "^172.0.0", "jose": "^6.2.2", "ldrs": "^1.1.9", "lodash-es": "^4.17.23", @@ -88,7 +88,7 @@ "lucide-react": "^1.16.0", "next": "^16.1.6", "nextjs-toploader": "^3.9.17", - "node-domexception": "^1.0.0", + "node-domexception": "^2.0.2", "react": "^19.2.4", "react-day-picker": "^10.0.0", "react-dom": "^19.2.4", @@ -105,7 +105,7 @@ "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", "tw-animate-css": "^1.4.0", - "uuid": "^11.0.5", + "uuid": "^14.0.0", "zod": "^4.3.6" }, "devDependencies": { diff --git a/public/openapi/openapi.yaml b/public/openapi/openapi.yaml index 85ba9af5..970a8fcc 100644 --- a/public/openapi/openapi.yaml +++ b/public/openapi/openapi.yaml @@ -6,7 +6,7 @@ openapi: 3.1.0 info: title: GhostClass API - version: 4.3.6 + version: 4.3.7 description: | **GhostClass API** provides endpoints for authentication, profile synchronization, attendance integrations with EzyGo, telemetry, and build provenance. diff --git a/src/__tests__/proxy.test.ts b/src/__tests__/proxy.test.ts index 89e58192..fa40e6f5 100644 --- a/src/__tests__/proxy.test.ts +++ b/src/__tests__/proxy.test.ts @@ -36,7 +36,7 @@ describe("proxy – Scenario A: unauthenticated user on protected route", () => mockGetUser.mockResolvedValue({ data: { user: null }, error: null }); }); - it("redirects to / and clears session cookies (but preserves csrf_token)", async () => { + it("redirects to / and clears session cookies", async () => { const request = new NextRequest("http://localhost/dashboard"); const response = await proxy(request); @@ -57,10 +57,7 @@ describe("proxy – Scenario A: unauthenticated user on protected route", () => expect(isDeleted("ezygo_access_token")).toBe(true); expect(isDeleted("terms_version")).toBe(true); expect(isDeleted("terms_redirect_count")).toBe(true); - // csrf_token is intentionally NOT cleared on unauthenticated redirects — only on - // explicit logout. Clearing it here would break the login flow when the 30-minute - // per-tab throttle in useCSRFToken is still active (cookie gone, sessionStorage stale). - expect(isDeleted("csrf_token")).toBe(false); + expect(isDeleted("csrf_token")).toBe(true); }); }); diff --git a/src/app/(protected)/__tests__/ProtectedLayout.test.tsx b/src/app/(protected)/__tests__/ProtectedLayout.test.tsx index 6b6c1bc3..c1a023c3 100644 --- a/src/app/(protected)/__tests__/ProtectedLayout.test.tsx +++ b/src/app/(protected)/__tests__/ProtectedLayout.test.tsx @@ -3,10 +3,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, act, waitFor } from '@testing-library/react'; import ProtectedLayout from '../layout'; import { useScroll } from 'framer-motion'; -import { createClient } from '@/lib/supabase/client'; -import { handleLogout, isAuthSessionMissingError } from '@/lib/security/auth'; -import { logger } from '@/lib/logger'; -import * as Sentry from "@sentry/nextjs"; // Mock hooks vi.mock("@/hooks/users/institutions", () => ({ @@ -30,44 +26,6 @@ vi.mock("@/components/error-boundary", () => ({ ErrorBoundary: ({ children }: any) =>
{children}
, })); -// Mock Supabase -vi.mock('@/lib/supabase/client', () => { - const mockUser = { id: '123' }; - const mockGetUser = vi.fn().mockResolvedValue({ data: { user: mockUser }, error: null }); - const client = { - auth: { - getUser: mockGetUser, - }, - }; - return { - createClient: vi.fn(() => client), - }; -}); - -// Mock Auth logic -vi.mock('@/lib/security/auth', () => ({ - handleLogout: vi.fn().mockResolvedValue(undefined), - isAuthSessionMissingError: vi.fn().mockReturnValue(false), -})); - -vi.mock('@/lib/logger', () => ({ - logger: { - error: vi.fn(), - }, -})); - -vi.mock('@sentry/nextjs', () => ({ - captureException: vi.fn(), -})); - -// Mock next/navigation -const mockReplace = vi.fn(); -vi.mock('next/navigation', () => ({ - useRouter: () => ({ - replace: mockReplace, - }), -})); - // Mock framer-motion vi.mock('framer-motion', async () => { const React = await import('react'); @@ -105,9 +63,6 @@ describe('ProtectedLayout', () => { vi.mocked(useScroll).mockReturnValue({ scrollY: { on: mockOn } as any, } as any); - - const client = createClient(); - (client.auth.getUser as any).mockResolvedValue({ data: { user: { id: '123' } }, error: null }); }); it('renders children and essential components', async () => { @@ -148,56 +103,4 @@ describe('ProtectedLayout', () => { expect(screen.getByTestId('motion-div').getAttribute('data-animate')).toBe('visible'); }); }); - - it('handles auth session missing error', async () => { - const client = createClient(); - const authError = { message: 'Session missing' }; - (client.auth.getUser as any).mockResolvedValue({ data: { user: null }, error: authError }); - vi.mocked(isAuthSessionMissingError).mockReturnValue(true); - - render(
Content
); - - await waitFor(() => { - expect(handleLogout).toHaveBeenCalled(); - }); - }); - - it('handles generic auth error and logs out', async () => { - const client = createClient(); - const authError = new Error('Database down'); - (client.auth.getUser as any).mockResolvedValue({ data: { user: null }, error: authError }); - vi.mocked(isAuthSessionMissingError).mockReturnValue(false); - - render(
Content
); - - await waitFor(() => { - expect(logger.error).toHaveBeenCalledWith("Auth check failed:", "Database down"); - expect(Sentry.captureException).toHaveBeenCalledWith(authError, expect.any(Object)); - expect(handleLogout).toHaveBeenCalled(); - }); - }); - - it('handles case where user is null but no error', async () => { - const client = createClient(); - (client.auth.getUser as any).mockResolvedValue({ data: { user: null }, error: null }); - - render(
Content
); - - await waitFor(() => { - expect(handleLogout).toHaveBeenCalled(); - }); - }); - - it('redirects to root if logout fails after auth error', async () => { - const client = createClient(); - const authError = new Error('Auth failed'); - (client.auth.getUser as any).mockResolvedValue({ data: { user: null }, error: authError }); - vi.mocked(handleLogout).mockRejectedValueOnce(new Error('Logout failed')); - - render(
Content
); - - await waitFor(() => { - expect(mockReplace).toHaveBeenCalledWith('/'); - }); - }); }); diff --git a/src/app/(protected)/__tests__/layout.test.tsx b/src/app/(protected)/__tests__/layout.test.tsx index a6820120..77c3067f 100644 --- a/src/app/(protected)/__tests__/layout.test.tsx +++ b/src/app/(protected)/__tests__/layout.test.tsx @@ -1,18 +1,9 @@ /** @vitest-environment jsdom */ import { describe, it, vi, expect, beforeEach } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import ProtectedLayout from '../layout'; -import { useRouter } from 'next/navigation'; -import { createClient } from '@/lib/supabase/client'; -import { handleLogout } from '@/lib/security/auth'; // Mock all the things -vi.mock('next/navigation', () => ({ - useRouter: vi.fn(() => ({ - replace: vi.fn(), - })), -})); - vi.mock('@/hooks/users/institutions', () => ({ useInstitutions: vi.fn(), })); @@ -21,29 +12,6 @@ vi.mock('@/hooks/use-csrf-token', () => ({ useCSRFToken: vi.fn(), })); -vi.mock('@/lib/supabase/client', () => ({ - createClient: vi.fn(() => ({ - auth: { - getUser: vi.fn(), - }, - })), -})); - -vi.mock('@/lib/security/auth', () => ({ - handleLogout: vi.fn(), - isAuthSessionMissingError: vi.fn((err) => err.message === 'session missing'), -})); - -vi.mock('@/lib/logger', () => ({ - logger: { - error: vi.fn(), - }, -})); - -vi.mock('@sentry/nextjs', () => ({ - captureException: vi.fn(), -})); - vi.mock('framer-motion', async (importOriginal) => { const actual = await importOriginal() as any; return { @@ -73,23 +41,11 @@ vi.mock('@/components/error-boundary', () => ({ })); describe('ProtectedLayout', () => { - const mockRouter = { - replace: vi.fn(), - }; - beforeEach(() => { vi.clearAllMocks(); - vi.mocked(useRouter).mockReturnValue(mockRouter as any); }); - it('renders children and navbar/footer when authenticated', async () => { - const mockSupabase = { - auth: { - getUser: vi.fn().mockResolvedValue({ data: { user: { id: '123' } }, error: null }), - }, - }; - vi.mocked(createClient).mockReturnValue(mockSupabase as any); - + it('renders children and navbar/footer', () => { render(
Protected Content
@@ -99,67 +55,5 @@ describe('ProtectedLayout', () => { expect(screen.getByTestId('navbar')).toBeInTheDocument(); expect(screen.getByTestId('footer')).toBeInTheDocument(); expect(screen.getByTestId('child')).toBeInTheDocument(); - - await waitFor(() => { - expect(mockSupabase.auth.getUser).toHaveBeenCalled(); - }); - }); - - it('calls handleLogout when user is missing', async () => { - const mockSupabase = { - auth: { - getUser: vi.fn().mockResolvedValue({ data: { user: null }, error: null }), - }, - }; - vi.mocked(createClient).mockReturnValue(mockSupabase as any); - - render( - -
Content
-
- ); - - await waitFor(() => { - expect(handleLogout).toHaveBeenCalled(); - }); - }); - - it('calls handleLogout when session missing error occurs', async () => { - const mockSupabase = { - auth: { - getUser: vi.fn().mockResolvedValue({ data: { user: null }, error: new Error('session missing') }), - }, - }; - vi.mocked(createClient).mockReturnValue(mockSupabase as any); - - render( - -
Content
-
- ); - - await waitFor(() => { - expect(handleLogout).toHaveBeenCalled(); - }); - }); - - it('redirects to root when auth check fails completely', async () => { - const mockSupabase = { - auth: { - getUser: vi.fn().mockRejectedValue(new Error('Fatal auth error')), - }, - }; - vi.mocked(createClient).mockReturnValue(mockSupabase as any); - vi.mocked(handleLogout).mockRejectedValue(new Error('Logout failed')); - - render( - -
Content
-
- ); - - await waitFor(() => { - expect(mockRouter.replace).toHaveBeenCalledWith('/'); - }); }); }); diff --git a/src/app/(protected)/dashboard/DashboardClient.tsx b/src/app/(protected)/dashboard/DashboardClient.tsx index 5112d37c..0b6ae893 100644 --- a/src/app/(protected)/dashboard/DashboardClient.tsx +++ b/src/app/(protected)/dashboard/DashboardClient.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { AnimatePresence, domAnimation, @@ -8,6 +8,7 @@ import { m as motion, } from "framer-motion"; import { toast } from "sonner"; +import { ChevronLeft, ChevronRight } from "lucide-react"; import { logger } from "@/lib/logger"; import { Skeleton } from "@/components/ui/skeleton"; import { @@ -16,12 +17,6 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, -} from "@/components/ui/select"; import { useProfile } from "@/hooks/users/profile"; import { useAllCourseDetails, @@ -54,6 +49,7 @@ import { calculateCurrentAcademicInfo } from "@/lib/logic/academic"; import { AddCourseDialog } from "@/components/attendance/AddCourseDialog"; import { AddAttendanceDialog } from "@/components/attendance/AddAttendanceDialog"; import { EditInstructorDialog } from "@/components/attendance/EditInstructorDialog"; +import { SelectClassDialog } from "@/components/attendance/SelectClassDialog"; import { useFetchCourseInstructors } from "@/hooks/courses/instructors"; import { ClassCourse, useFetchClassCourses } from "@/hooks/courses/useFetchClassCourses"; import { useSyncOnMount } from "@/hooks/use-sync-on-mount"; @@ -101,6 +97,47 @@ type InitialDashboardData = { attendance: unknown; } | null; +type AcademicSemester = "even" | "odd"; + +type AcademicPeriod = { + semester: AcademicSemester; + year: string; +}; + +const academicYearPattern = /^(\d{2}|\d{4})-(\d{2}|\d{4})$/; + +const formatAcademicYear = (startYear: number) => `${startYear}-${String(startYear + 1).slice(-2)}`; + +const parseAcademicYearStart = (year: string) => { + const match = academicYearPattern.exec(year.trim()); + if (!match) return null; + + const startValue = match[1].length === 2 ? `20${match[1]}` : match[1]; + const startYear = Number.parseInt(startValue, 10); + return Number.isNaN(startYear) ? null : startYear; +}; + +const shiftAcademicPeriod = ( + semester: AcademicSemester, + year: string, + direction: "previous" | "next", +): AcademicPeriod | null => { + const startYear = parseAcademicYearStart(year); + if (startYear == null) return null; + + if (direction === "previous") { + return semester === "odd" + ? { semester: "even", year: formatAcademicYear(startYear - 1) } + : { semester: "odd", year: formatAcademicYear(startYear) }; + } + + return semester === "odd" + ? { semester: "even", year: formatAcademicYear(startYear) } + : { semester: "odd", year: formatAcademicYear(startYear + 1) }; +}; + +const formatAcademicPeriod = (period: AcademicPeriod) => `${period.semester.toUpperCase()} ${period.year}`; + interface DashboardClientProps { initialData?: InitialDashboardData; serverError?: string | null; @@ -178,7 +215,7 @@ const getSortPriority = (item: { isDisabled?: boolean; isNew?: boolean }) => { }; export default function DashboardClient({ initialData, serverError }: DashboardClientProps) { - const { data: rawProfile, isLoading: isLoadingProfile } = useProfile({ sync: true }); + const { data: rawProfile, isLoading: isLoadingProfile, refetch: refetchProfile } = useProfile({ sync: true, force: true }); const profile = rawProfile as UserProfile | undefined; const queryClient = useQueryClient(); const setSemesterMutation = useSetSemester(); @@ -213,23 +250,41 @@ export default function DashboardClient({ initialData, serverError }: DashboardC const [showConfirmDialog, setShowConfirmDialog] = useState(false); const [isAddCourseOpen, setIsAddCourseOpen] = useState(false); const [isAddAttendanceOpen, setIsAddAttendanceOpen] = useState(false); - const [pendingChange, setPendingChange] = useState<{ type: "semester" | "academicYear"; value: string } | null>(null); + const [pendingChange, setPendingChange] = useState(null); const [isEditInstructorOpen, setIsEditInstructorOpen] = useState(false); const [selectedInstructorCourse, setSelectedInstructorCourse] = useState<{ code: string; name: string; initialName: string } | null>(null); const [isUpdating, setIsUpdating] = useState(false); + const [isSelectClassOpen, setIsSelectClassOpen] = useState(false); + const academicShiftLockRef = useRef(false); - const { syncCompleted } = useSyncOnMount({ - username: profile?.username, - userId: profile?.id ? String(profile.id) : undefined, - enabled: !!profile?.username, + const { syncCompleted } = useSyncOnMount({ + username: profile?.username, + userId: profile?.id ? String(profile.id) : undefined, + enabled: !!profile?.username, sentryLocation: "DashboardClient", sentryTag: "background_sync" }); + useEffect(() => { + const shouldOpen = !!(syncCompleted && profile && !profile.class?.id); + const timer = setTimeout(() => { + setIsSelectClassOpen(shouldOpen); + }, 0); + return () => clearTimeout(timer); + }, [syncCompleted, profile]); + + const isAttendanceStale = + initialData?.attendance && + typeof initialData.attendance === "object" && + "studentAttendanceData" in initialData.attendance && + initialData.attendance.studentAttendanceData && + typeof initialData.attendance.studentAttendanceData === "object" && + "stale" in initialData.attendance.studentAttendanceData && + (initialData.attendance.studentAttendanceData as { stale?: unknown }).stale === true; + const { data: rawAttendanceData, isLoading: isLoadingAttendance, refetch: refetchAttendance } = useAttendanceReport(currentSem, currentYear, { enabled: syncCompleted, - /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ - initialData: (selectedSemester === null && selectedYear === null) ? (initialData?.attendance as any) : undefined, + initialData: isAttendanceStale ? undefined : (initialData?.attendance as AttendanceReport ?? undefined), }); const attendanceData = rawAttendanceData as AttendanceReport | undefined; @@ -237,10 +292,11 @@ export default function DashboardClient({ initialData, serverError }: DashboardC semester: currentSem, year: currentYear, enabled: syncCompleted && !!currentSem && !!currentYear, + initialData: initialData?.courses as { courses: Record } ?? undefined, }); const coursesData = rawCoursesData as { courses: Record } | undefined; - const { data: rawTrackingData, isLoading: isLoadingTracking, refetch: refetchTracking } = useTrackingData(profile, { + const { data: rawTrackingData, refetch: refetchTracking } = useTrackingData(profile, { semester: currentSem, year: currentYear, enabled: syncCompleted, @@ -251,11 +307,11 @@ export default function DashboardClient({ initialData, serverError }: DashboardC const { data: rawClassCourses } = useFetchClassCourses({ semester: currentSem, year: currentYear, enabled: syncCompleted && !!profile?.class?.id }); const classCourses = rawClassCourses as ClassCourse[] | undefined; - const { getCourseCodeById: getCourseCode } = useCourseLookup({ - coursesData, - classCourses, + const { getCourseCodeById: getCourseCode } = useCourseLookup({ + coursesData, + classCourses, /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ - attendanceData: attendanceData as any + attendanceData: attendanceData as any }) as { getCourseCodeById: (id: string | number) => string }; const courseList = useMemo(() => { @@ -280,38 +336,50 @@ export default function DashboardClient({ initialData, serverError }: DashboardC const { disabledCodes } = useDisabledCourses({ academicYear: currentYear, semester: currentSem }); + const pendingPeriodLabel = pendingChange ? formatAcademicPeriod(pendingChange) : null; + + const requestAcademicShift = (direction: "previous" | "next") => { + if (!effectiveSemester || !effectiveYear || isUpdating || academicShiftLockRef.current) return; + + const nextPeriod = shiftAcademicPeriod(effectiveSemester, effectiveYear, direction); + if (!nextPeriod) { + toast.error("Could not compute the next academic period."); + return; + } + + academicShiftLockRef.current = true; + setPendingChange(nextPeriod); + setShowConfirmDialog(true); + }; + const handleConfirmChange = async () => { if (!pendingChange || !profile?.username || isUpdating) return; setIsUpdating(true); setShowConfirmDialog(false); - await queryClient.cancelQueries(); - queryClient.invalidateQueries(); try { - if (pendingChange.type === "semester") { - await setSemesterMutation.mutateAsync({ default_semester: pendingChange.value as "even" | "odd" }); - setSelectedSemester(pendingChange.value as "even" | "odd"); - } else { - await setAcademicYearMutation.mutateAsync({ default_academic_year: pendingChange.value }); - setSelectedYear(pendingChange.value); + if (pendingChange.year !== effectiveYear) { + await setAcademicYearMutation.mutateAsync({ default_academic_year: pendingChange.year }); + } + + if (pendingChange.semester !== effectiveSemester) { + await setSemesterMutation.mutateAsync({ default_semester: pendingChange.semester }); } + + setSelectedSemester(pendingChange.semester); + setSelectedYear(pendingChange.year); queryClient.invalidateQueries({ queryKey: ["profile"] }); + await refetchProfile(); } catch (error) { logger.error("Update Failed:", error); toast.error("Failed to update settings"); } finally { setIsUpdating(false); setPendingChange(null); + academicShiftLockRef.current = false; } }; - const academicYears = useMemo(() => { - const current = new Date().getFullYear(); - const years = []; - for (let y = 2022; y <= current; y++) years.push(`${y}-${(y + 1).toString().slice(-2)}`); - return years; - }, []); - const activeCourseCount = useMemo(() => getActiveCourseStats(attendanceData, trackingData || [], coursesData, classCourses || [], disabledCodes, effectiveSemester, effectiveYear, getCourseCode), [attendanceData, trackingData, coursesData, classCourses, disabledCodes, effectiveSemester, effectiveYear, getCourseCode]); const filteredChartData = useMemo(() => { @@ -395,19 +463,19 @@ export default function DashboardClient({ initialData, serverError }: DashboardC } if (attendanceData) { return ( - }} - classCourses={classCourses} + }} + classCourses={classCourses} /> ); } return
No data
; }; - if ((!profile && !isLoadingProfile) || !currentSem || !currentYear || !syncCompleted || isLoadingAttendance || isLoadingCourses || isLoadingTracking || isLoadingAllCourseSummaries || isUpdating) return ; + if ((!profile && !isLoadingProfile) || !currentSem || !currentYear || !syncCompleted) return ; const isGlobalLoading = isLoadingProfile || isUpdating || isSettingsLoading || setSemesterMutation.isPending || setAcademicYearMutation.isPending || !syncCompleted; @@ -417,21 +485,43 @@ export default function DashboardClient({ initialData, serverError }: DashboardC {isGlobalLoading && (

Syncing...

)}
-
+

Welcome back, {profile?.first_name} {profile?.last_name}!

-
-

- Semester: - - Year: - -

+ + {profile?.class?.name || "Unassigned"} + +

+ For students juggling classes, internals, labs, submissions, caffeine, and “I’ll study tomorrow” energy ☕📚 +

+
+
+ +
+
+ Current +
+
+ {effectiveSemester?.toUpperCase()} {effectiveYear} +
+
+ +
@@ -439,30 +529,59 @@ export default function DashboardClient({ initialData, serverError }: DashboardC []} customInstructors={customInstructors || []} allCourseSummaries={allCourseSummaries as Record} profile={profile as unknown as Record} onEditInstructor={(course: Record, _name: string, hasCustomName: boolean, customInstructor?: unknown) => { - const customInst = customInstructor as CustomInstructor | undefined; - setSelectedInstructorCourse({ code: String(course.code || course.id).toUpperCase().replace(/[\s\u00A0-]/g, ""), name: String(course.name || ""), initialName: hasCustomName ? (customInst?.instructor_name ?? "") : "" }); - setIsEditInstructorOpen(true); - }} onAddCourse={() => setIsAddCourseOpen(true)} /> + const customInst = customInstructor as CustomInstructor | undefined; + setSelectedInstructorCourse({ code: String(course.code || course.id).toUpperCase().replace(/[\s\u00A0-]/g, ""), name: String(course.name || ""), initialName: hasCustomName ? (customInst?.instructor_name ?? "") : "" }); + setIsEditInstructorOpen(true); + }} onAddCourse={() => { + if (!profile?.class?.id) { + toast.error("You have not assigned a class yet."); + } else { + setIsAddCourseOpen(true); + } + }} />
- - Attendance Calendar - - {renderAttendanceCalendarContent()} - - -
+ + Attendance Calendar + + {renderAttendanceCalendarContent()} + + +
- + { + setShowConfirmDialog(open); + if (!open) { + setPendingChange(null); + academicShiftLockRef.current = false; + } + }} + > - Confirm ChangeChange {pendingChange?.type}? - { setShowConfirmDialog(false); setPendingChange(null); }}>CancelConfirm + + Confirm academic period change + + Change from {effectiveSemester?.toUpperCase()} {effectiveYear} to {pendingPeriodLabel}? + + + { setShowConfirmDialog(false); setPendingChange(null); academicShiftLockRef.current = false; }}>CancelConfirm Promise.all([refetchAttendance(), refetchTracking()])} selectedSemester={currentSem} selectedYear={currentYear} /> + {currentSem && currentYear && ( + + )}
diff --git a/src/app/(protected)/dashboard/__tests__/DashboardClient.basic.test.tsx b/src/app/(protected)/dashboard/__tests__/DashboardClient.basic.test.tsx index ae5e13e6..71afe5cf 100644 --- a/src/app/(protected)/dashboard/__tests__/DashboardClient.basic.test.tsx +++ b/src/app/(protected)/dashboard/__tests__/DashboardClient.basic.test.tsx @@ -61,6 +61,7 @@ vi.mock('lucide-react', async () => { XCircle: Icon, XIcon: Icon, BookOpen: Icon, + GraduationCap: Icon, }; }); diff --git a/src/app/(protected)/dashboard/__tests__/DashboardClient.coverage.test.tsx b/src/app/(protected)/dashboard/__tests__/DashboardClient.coverage.test.tsx index 8dc6c9ff..692cb24d 100644 --- a/src/app/(protected)/dashboard/__tests__/DashboardClient.coverage.test.tsx +++ b/src/app/(protected)/dashboard/__tests__/DashboardClient.coverage.test.tsx @@ -108,6 +108,10 @@ vi.mock('@/components/attendance/EditInstructorDialog', () => ({ EditInstructorDialog: ({ open }: any) => (open ?
: null), })); +vi.mock('@/components/attendance/SelectClassDialog', () => ({ + SelectClassDialog: () => null, +})); + vi.mock('next/dynamic', () => ({ default: () => { return function DynamicComponent() { @@ -127,10 +131,10 @@ vi.mock('@/components/ui/select', () => ({ {children} ), - SelectTrigger: ({ children }: any) => , - SelectContent: ({ children }: any) => <>{children}, + SelectTrigger: ({ children }: any) => , + SelectContent: ({ children }: any) =>
{children}
, SelectItem: ({ children, value }: any) => , - SelectValue: ({ children }: any) => <>{children}, + SelectValue: ({ children }: any) => {children}, })); vi.mock('@/components/ui/alert-dialog', () => ({ @@ -162,7 +166,7 @@ vi.mock('@/components/loading', () => ({ })); describe('DashboardClient', () => { - const mockProfile = { id: '123', username: 'testuser', first_name: 'Test' }; + const mockProfile = { id: '123', username: 'testuser', first_name: 'Test', class: { id: '1', name: 'Test Class' } }; beforeEach(() => { vi.useRealTimers(); @@ -191,8 +195,21 @@ describe('DashboardClient', () => { }); expect(screen.getByText(/Welcome back/i)).toBeInTheDocument(); - expect(screen.getByDisplayValue('odd')).toBeInTheDocument(); - expect(screen.getByDisplayValue('2024-25')).toBeInTheDocument(); + expect(screen.getAllByText(/ODD 2024-25/i).length).toBeGreaterThan(0); + }); + + it('does not seed the attendance query with initial data', async () => { + render(); + + await waitFor(() => { + expect(screen.queryByTestId('loading-overlay')).not.toBeInTheDocument(); + }); + + expect(vi.mocked(useAttendanceReport)).toHaveBeenCalledWith( + 'odd', + '2024-25', + expect.not.objectContaining({ initialData: expect.anything() }), + ); }); it('handles serverError by showing a toast', () => { @@ -212,10 +229,9 @@ describe('DashboardClient', () => { expect(screen.queryByTestId('loading-overlay')).not.toBeInTheDocument(); }); - const selects = screen.getAllByTestId('select-component'); - fireEvent.change(selects[0], { target: { value: 'even' } }); + fireEvent.click(screen.getByLabelText('Go to next academic period')); - expect(screen.getByText(/Confirm Change/i)).toBeInTheDocument(); + expect(screen.getByText(/Confirm academic period change/i)).toBeInTheDocument(); }); it('calculates stats correctly', async () => { @@ -260,9 +276,10 @@ describe('DashboardClient', () => { expect(screen.getByText(/No courses found/i)).toBeInTheDocument(); }); - const addBtn = screen.getByText('Add Your First Course'); - fireEvent.click(addBtn); - expect(screen.getByTestId('add-course-dialog')).toBeInTheDocument(); + fireEvent.click(screen.getByText('Add Your First Course')); + await waitFor(() => { + expect(screen.getByTestId('add-course-dialog')).toBeInTheDocument(); + }); }); it('handles confirm and cancel in AlertDialog', async () => { @@ -272,15 +289,14 @@ describe('DashboardClient', () => { render(); await waitFor(() => expect(screen.queryByTestId('loading-overlay')).not.toBeInTheDocument()); - const selects = screen.getAllByTestId('select-component'); - fireEvent.change(selects[0], { target: { value: 'even' } }); + fireEvent.click(screen.getByLabelText('Go to next academic period')); // Cancel first fireEvent.click(screen.getByText('Cancel')); expect(screen.queryByTestId('alert-dialog')).not.toBeInTheDocument(); // Trigger again and confirm - fireEvent.change(selects[0], { target: { value: 'even' } }); + fireEvent.click(screen.getByLabelText('Go to next academic period')); fireEvent.click(screen.getByText('Confirm')); await waitFor(() => { expect(mockMutate).toHaveBeenCalled(); @@ -322,8 +338,9 @@ describe('DashboardClient', () => { render(); await waitFor(() => expect(screen.queryByTestId('loading-overlay')).not.toBeInTheDocument()); - const addCard = screen.getByText(/Can't find a course/i); - fireEvent.click(addCard); - expect(screen.getByTestId('add-course-dialog')).toBeInTheDocument(); + fireEvent.click(screen.getByText(/Can't find a course/i)); + await waitFor(() => { + expect(screen.getByTestId('add-course-dialog')).toBeInTheDocument(); + }); }); }); diff --git a/src/app/(protected)/dashboard/__tests__/DashboardClient.test.tsx b/src/app/(protected)/dashboard/__tests__/DashboardClient.test.tsx index f25c0b89..7119932d 100644 --- a/src/app/(protected)/dashboard/__tests__/DashboardClient.test.tsx +++ b/src/app/(protected)/dashboard/__tests__/DashboardClient.test.tsx @@ -31,7 +31,7 @@ type MotionProps = MockComponentProps & { // Mock all hooks vi.mock('@/hooks/users/profile', () => ({ - useProfile: vi.fn(() => ({ data: { first_name: 'Test', last_name: 'User', username: 'testuser', id: 1 }, isLoading: false, isFetching: false, refetch: vi.fn() })), + useProfile: vi.fn(() => ({ data: { first_name: 'Test', last_name: 'User', username: 'testuser', id: 1, class: { id: 1, name: 'Test Class' } }, isLoading: false, isFetching: false, refetch: vi.fn() })), })); vi.mock('@/hooks/courses/attendance', () => ({ @@ -183,6 +183,10 @@ vi.mock('@/components/attendance/EditInstructorDialog', () => ({ EditInstructorDialog: ({ open }: { open?: boolean }) => open ?
: null, })); +vi.mock('@/components/attendance/SelectClassDialog', () => ({ + SelectClassDialog: () => null, +})); + // Mock axios vi.mock('@/lib/axios', () => ({ default: { @@ -216,8 +220,8 @@ describe('DashboardClient', () => { ); - const changeButtons = await screen.findAllByText('Change to EVEN'); - fireEvent.click(changeButtons[0]); + const nextButton = await screen.findByLabelText('Go to next academic period'); + fireEvent.click(nextButton); expect(screen.getByTestId('alert-dialog')).toBeInTheDocument(); diff --git a/src/app/(protected)/dashboard/components/StatsPanel.tsx b/src/app/(protected)/dashboard/components/StatsPanel.tsx index b37b10a8..4be0a4e0 100644 --- a/src/app/(protected)/dashboard/components/StatsPanel.tsx +++ b/src/app/(protected)/dashboard/components/StatsPanel.tsx @@ -117,7 +117,7 @@ export function StatsPanel({ stats, isLoadingAttendance, targetPercentage }: Sta )}
-

+

{isLoadingAttendance ? : ( @@ -150,7 +150,7 @@ export function StatsPanel({ stats, isLoadingAttendance, targetPercentage }: Sta  total )} -

+
diff --git a/src/app/(protected)/dashboard/components/__tests__/StatsPanel.test.tsx b/src/app/(protected)/dashboard/components/__tests__/StatsPanel.test.tsx new file mode 100644 index 00000000..c97b402e --- /dev/null +++ b/src/app/(protected)/dashboard/components/__tests__/StatsPanel.test.tsx @@ -0,0 +1,55 @@ +/** @vitest-environment jsdom */ +import type { ReactNode } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { StatsPanel } from '../StatsPanel'; + +vi.mock('@/components/ui/card', () => ({ + Card: ({ children }: { children?: ReactNode }) =>
{children}
, + CardContent: ({ children }: { children?: ReactNode }) =>
{children}
, + CardHeader: ({ children }: { children?: ReactNode }) =>
{children}
, + CardTitle: ({ children }: { children?: ReactNode }) =>
{children}
, +})); + +vi.mock('@/components/ui/skeleton', () => ({ + Skeleton: () =>
, +})); + +vi.mock('framer-motion', () => { + const mockComponent = ({ children, ...rest }: { children?: ReactNode; [key: string]: unknown }) => { + return
{children}
; + }; + + return { + motion: { + div: mockComponent, + }, + m: { + div: mockComponent, + }, + }; +}); + +describe('StatsPanel', () => { + it('renders the loading footer without paragraph nesting', () => { + const { container } = render( + , + ); + + expect(screen.getAllByTestId('skeleton')).toHaveLength(3); + expect(container.querySelector('p')).toBeNull(); + }); +}); \ No newline at end of file diff --git a/src/app/(protected)/layout.tsx b/src/app/(protected)/layout.tsx index a1bfb190..f08db517 100644 --- a/src/app/(protected)/layout.tsx +++ b/src/app/(protected)/layout.tsx @@ -4,15 +4,10 @@ import { Navbar } from "@/components/layout/private-navbar"; import { Footer } from "@/components/layout/footer"; import { useInstitutions } from "@/hooks/users/institutions"; import { useEffect, useState, useRef } from "react"; -import { useRouter } from "next/navigation"; import { Toaster } from "@/components/toaster"; import { LazyMotion, domAnimation, m as motion, useScroll } from "framer-motion"; import { cn } from "@/lib/utils"; import { ErrorBoundary } from "@/components/error-boundary"; -import { createClient } from "@/lib/supabase/client"; -import { handleLogout, isAuthSessionMissingError } from "@/lib/security/auth"; -import { logger } from "@/lib/logger"; -import * as Sentry from "@sentry/nextjs"; import { useCSRFToken } from "@/hooks/use-csrf-token"; import { OutageProvider } from "@/providers/outage-provider"; @@ -21,7 +16,6 @@ export default function ProtectedLayout({ }: { children: React.ReactNode; }) { - const router = useRouter(); // IMPORTANT — No loading state is tracked here. // // The server middleware (proxy.ts) already verified the Supabase session @@ -35,7 +29,7 @@ export default function ProtectedLayout({ const { scrollY } = useScroll(); const lastScrollY = useRef(0); const ticking = useRef(false); - const supabaseRef = useRef(createClient()); + const isHiddenRef = useRef(false); // Initialize CSRF token useCSRFToken(); @@ -48,9 +42,11 @@ export default function ProtectedLayout({ const shouldHide = latest > previous && latest > 150; const shouldShow = latest <= previous || latest <= 150; - if (shouldHide && !isHidden) { + if (shouldHide && !isHiddenRef.current) { + isHiddenRef.current = true; setIsHidden(true); - } else if (shouldShow && isHidden) { + } else if (shouldShow && isHiddenRef.current) { + isHiddenRef.current = false; setIsHidden(false); } @@ -64,7 +60,7 @@ export default function ProtectedLayout({ return () => { unsubscribe(); }; - }, [scrollY, isHidden]); + }, [scrollY]); // useInstitutions is also called inside ; React Query deduplicates the // network request so there is no extra fetch here. We only subscribe to it at @@ -75,51 +71,15 @@ export default function ProtectedLayout({ useInstitutions(); useEffect(() => { - let active = true; - - const checkUser = async () => { - try { - const { data: { user }, error } = await supabaseRef.current.auth.getUser(); - // Handle auth session missing errors — force full logout to clear cookies/storage - if (error && isAuthSessionMissingError(error)) { - active = false; - await handleLogout(); - return; - } - if (error) throw error; - - // No Supabase user means the session is gone — force full logout so httpOnly - // cookies (ezygo_access_token, CSRF) and client storage are properly cleared - if (!user) { - active = false; - await handleLogout(); - return; - } - - // At this point, Supabase has confirmed a valid user session. - } catch (err) { - if (!active) return; - // Log the error for debugging, then attempt logout - logger.error("Auth check failed:", err instanceof Error ? err.message : String(err)); - Sentry.captureException(err, { - tags: { type: "client_auth_check_failure", location: "protected/layout" }, - }); - try { - await handleLogout(); - } catch (logoutErr) { - // If logout also fails, force navigation to login page - logger.error("Logout failed after auth check error:", logoutErr instanceof Error ? logoutErr.message : String(logoutErr)); - router.replace("/"); - } - } - }; - - checkUser(); - - return () => { - active = false; - }; - }, [router]); + // No client-side auth gate here. + // + // The server middleware (`proxy.ts`) already validates the Supabase + // session before this layout renders. On local dev, the browser-side + // Supabase client can temporarily report no user immediately after login + // even when the server session is valid, which caused an unwanted logout. + // We keep the client session code out of the critical path and let the + // server remain the source of truth. + }, []); return ( diff --git a/src/app/(protected)/leave-applications/LeaveClient.tsx b/src/app/(protected)/leave-applications/LeaveClient.tsx index d774eedf..0ad6b167 100644 --- a/src/app/(protected)/leave-applications/LeaveClient.tsx +++ b/src/app/(protected)/leave-applications/LeaveClient.tsx @@ -87,8 +87,20 @@ const getLeaveStatus = (approvers?: LeaveApprover[] | null): StatusInfo => { icon: Clock, }; + const inProgressStatus: StatusInfo = { + label: "In Progress", + color: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20", + icon: Clock, + }; + if (!approvers || approvers.length === 0) return defaultStatus; + // If any level has rejected, the entire leave is immediately Rejected + const hasRejected = approvers.some((a) => a.action_type === "reject"); + if (hasRejected) { + return STATUS_MAP.reject; + } + const actedApprovers = [...approvers] .filter((a) => a.action_type !== null || a.action_at !== null) .sort( @@ -98,14 +110,37 @@ const getLeaveStatus = (approvers?: LeaveApprover[] | null): StatusInfo => { if (actedApprovers.length === 0) return defaultStatus; + const hasPending = approvers.some( + (a) => a.action_type === null && a.action_at === null + ); const lastAction = actedApprovers[0].action_type; - return ( - STATUS_MAP[lastAction as string] || { - label: "In Progress", - color: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20", - icon: Clock, + + if (hasPending) { + // If there are pending steps, we cannot be fully Approved. + if (lastAction === "approve") { + return inProgressStatus; } - ); + if (lastAction === "forward") { + return STATUS_MAP.forward; + } + if (lastAction === "recommend") { + return STATUS_MAP.recommend; + } + return inProgressStatus; + } + + // All levels have acted, and none rejected. + if (lastAction === "approve") { + return STATUS_MAP.approve; + } + if (lastAction === "forward") { + return STATUS_MAP.forward; + } + if (lastAction === "recommend") { + return STATUS_MAP.recommend; + } + + return inProgressStatus; }; const formatBytes = (bytes: string | number) => { @@ -140,14 +175,16 @@ function WorkflowHistoryItem({ approver }: { approver: LeaveApprover }) { const styles = getApproverStyles(approver.action_type); return ( -
- +
+ - {approver.action_by_user?.first_name} {approver.action_by_user?.last_name} + + {approver.action_by_user?.first_name} {approver.action_by_user?.last_name} + -
+
+
Workflow History -
+
{validApprovers.map((approver) => ( ))} @@ -253,18 +290,18 @@ function LeaveCard({ leave, sessions }: { leave: LeaveItem; sessions: LeaveSessi )}`; }, [sessions]); - return ( - - -
+ return ( + + +
{status.label} - + Type: {leave.attendancetype?.name || "Leave"}
@@ -279,24 +316,26 @@ function LeaveCard({ leave, sessions }: { leave: LeaveItem; sessions: LeaveSessi )}
- -
-
+ +
+
Applied On -
+
- {formatDate(leave.created_at || null)} + + {formatDate(leave.created_at || null)} +
-
+
Leave Dates -
+
- + {dateRangeStr}
@@ -312,15 +351,15 @@ function LeaveCard({ leave, sessions }: { leave: LeaveItem; sessions: LeaveSessi {sessions.map((session) => (
S: {session.session?.name} - + {session.course?.name || session.course?.code || "Unknown Course"} @@ -340,16 +379,13 @@ function LeaveCard({ leave, sessions }: { leave: LeaveItem; sessions: LeaveSessi {leave.files.map((file) => (
- + {file.file_name} - + ({formatBytes(file.size_byte || 0)})
diff --git a/src/app/(protected)/scores/ScoresClient.tsx b/src/app/(protected)/scores/ScoresClient.tsx index a6346d56..476c1054 100644 --- a/src/app/(protected)/scores/ScoresClient.tsx +++ b/src/app/(protected)/scores/ScoresClient.tsx @@ -238,9 +238,9 @@ function ScoreCard({ {/* Course */} -
+
- + {getCourseName(exam)}
@@ -788,12 +788,12 @@ function CourseGroupsSection({ return (
{/* Course heading */} -
+
+ + + + + + + + + ); +} diff --git a/src/components/attendance/course-card.tsx b/src/components/attendance/course-card.tsx index 8e155c18..ce5eeb33 100644 --- a/src/components/attendance/course-card.tsx +++ b/src/components/attendance/course-card.tsx @@ -12,6 +12,7 @@ import { useTrackingData } from "@/hooks/tracker/useTrackingData"; import { useProfile } from "@/hooks/users/profile"; import { createClient } from "@/lib/supabase/client"; import { cn } from "@/lib/utils"; +import { reasonTextSchema } from "@/lib/validation/text"; import { toast } from "sonner"; import { useDisabledCourses } from "@/hooks/courses/useDisabledCourses"; import { useFetchUserSettings } from "@/hooks/users/settings"; @@ -816,7 +817,7 @@ function DisableCourseDialog({ return; } disableInFlightRef.current = true; - const reason = isOtherReason ? customReason.trim() : disableReason; + const reason = isOtherReason ? reasonTextSchema.parse(customReason) : disableReason; setIsDisabling(true); try { await disableCourse(courseCode, reason); diff --git a/src/components/layout/private-navbar.tsx b/src/components/layout/private-navbar.tsx index 6ca4a8ae..6aec1f70 100644 --- a/src/components/layout/private-navbar.tsx +++ b/src/components/layout/private-navbar.tsx @@ -135,7 +135,7 @@ export const Navbar = () => { // This allows bunk_calculator_enabled to be false (a valid value) while falling back // to a default when settings is not yet loaded or is explicitly null const currentBunkCalc = settings?.bunk_calculator_enabled !== undefined && - settings?.bunk_calculator_enabled !== null + settings?.bunk_calculator_enabled !== null ? settings.bunk_calculator_enabled : true; @@ -308,53 +308,53 @@ export const Navbar = () => { {/* Institution Selector */} {!institutionsLoading && institutions && institutions.length > 0 && pathname !== "/profile" && ( -
- - - {selectedInstitution && - institutions?.find( - (i) => i.id.toString() === selectedInstitution, - ) && ( -
-
- )} -
- - - {institutions.map((inst) => ( - -
-
-
- ))} -
- -
- )} + + + {selectedInstitution && + institutions?.find( + (i) => i.id.toString() === selectedInstitution, + ) && ( +
+
+ )} +
+
+ + {institutions.map((inst) => ( + +
+
+
+ ))} +
+ +
+ )}
@@ -554,41 +554,41 @@ export const Navbar = () => { {/* Institution Selector (Tablet/Mobile Only) */} {!institutionsLoading && institutions && institutions.length > 0 && ( -
-
-
-
- - - - - - {institutions.map((inst) => ( - - - {inst.institution.name} - - - ))} - - + + + + + + {institutions.map((inst) => ( + + + {inst.institution.name} + + + ))} + + +
-
- )} + )} { + const { url: supabaseUrl, key: supabaseKey } = getSupabaseConfig("client"); + const supabase = createBrowserClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! + supabaseUrl!, + supabaseKey! ); await supabase.auth.signOut(); router.push("/"); @@ -86,8 +89,8 @@ export function ServiceErrorView({ {/* Actions */}
- {showHome && ( - - -