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 @@
-
-
-
+
+
+
-
+
-
-
+
+
@@ -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
-
-
+
+



@@ -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