diff --git a/.cursor/rules/00-project.mdc b/.cursor/rules/00-project.mdc
deleted file mode 100644
index 4c5ba03..0000000
--- a/.cursor/rules/00-project.mdc
+++ /dev/null
@@ -1,14 +0,0 @@
----
-description: Project-wide NO SUS Monad experiment rules and handover map.
-alwaysApply: true
----
-
-Read `AGENTS.md` and `docs/STATUS.md` before changing code. This is a separate, testnet-only NO SUS
-experiment; do not read, edit, deploy, or reuse secrets from the production NO SUS repository.
-
-Preserve the threshold-custody honesty boundary: no plaintext, key, salt, filename, private key, or
-service-role key may reach the contract, client logs, or public environment. Do not claim a human read,
-legal proof, screenshot prevention, or independently verifiable deletion.
-
-Use focused checks in normal work. Ask the exact finalization question from `AGENTS.md` after every
-substantive file-changing response.
diff --git a/.cursor/rules/10-contracts.mdc b/.cursor/rules/10-contracts.mdc
deleted file mode 100644
index 9efbd0d..0000000
--- a/.cursor/rules/10-contracts.mdc
+++ /dev/null
@@ -1,9 +0,0 @@
----
-description: Solidity contract scope and receipt invariants.
-globs: contracts/**
-alwaysApply: false
----
-
-The contract records a ciphertext digest and wallet acknowledgement only. Never add plaintext, keys,
-salts, filenames, payment logic, admin bypasses, or an on-chain key-proof function. Preserve one open,
-recipient, and expiry invariants. Add a failing test before every contract behavior change.
diff --git a/.cursor/rules/20-web.mdc b/.cursor/rules/20-web.mdc
deleted file mode 100644
index b190ba6..0000000
--- a/.cursor/rules/20-web.mdc
+++ /dev/null
@@ -1,9 +0,0 @@
----
-description: Web experience and client security rules.
-globs: web/**
-alwaysApply: false
----
-
-Keep browser crypto and wallet actions in client components; keep Supabase service-role access in server
-route handlers only. Show explicit testnet and experimental labels. Dynamic wallet and receipt state must
-be accessible, keyboard usable, and respect reduced-motion preferences.
diff --git a/.env.example b/.env.example
index 5c580a0..ef8a342 100644
--- a/.env.example
+++ b/.env.example
@@ -1,19 +1,28 @@
-# Public browser configuration — safe to expose in the client bundle.
-NEXT_PUBLIC_MONAD_RPC_URL=https://rpc.testnet.monad.xyz
-NEXT_PUBLIC_MONAD_EXPLORER_URL=https://testnet.monadscan.com
-NEXT_PUBLIC_DROPS_CONTRACT_ADDRESS=
-NEXT_PUBLIC_SUPABASE_URL=
-NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=
+# NO SUS Canary: every secret and setting, with no values.
+# Nothing here is needed to run the app locally (it uses the hosted devnet backend).
+# Real values go in Supabase function secrets (`supabase secrets set ...`) or a git-ignored
+# .env file. Never commit a filled-in copy. See docs/SELF_HOSTING.md.
-# Server-only values — never prefix these with NEXT_PUBLIC_.
-SUPABASE_SERVICE_ROLE_KEY=
-LIT_NETWORK=naga-dev
-LIT_ACCOUNT_API_KEY=
-DEPLOYER_PRIVATE_KEY=
-CONFIRM_MONAD_DEPLOY=
+# --- Relayer: Supabase Edge Function `canary` (function secrets) ---
+# 64 hex chars, random (openssl rand -hex 32). Salts the anonymous reader tag. Never share it.
+CANARY_TAG_SALT=
+# Devnet relayer keypairs, comma-separated: base58 64-byte secret keys or solana-keygen JSON
+# arrays. Fund them at https://faucet.solana.com. Devnet only.
+SOLANA_RELAYER_KEYS=
+# Optional. Default https://api.devnet.solana.com
+SOLANA_RPC_URL=
+# Optional. Default MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr (SPL Memo)
+SOLANA_MEMO_PROGRAM=
+# Leave unset. Setting it turns on a legacy prototype path the product does not use.
+CANARY_CONTRACT_ADDRESS=
+# SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY are injected by Supabase. Never set them by hand
+# and never put the service-role key in the app.
-# Gate T1 live runner only (tool/threshold-spike). Never put these in NEXT_PUBLIC_*.
-# DROPS_CONTRACT_ADDRESS=
-# RECIPIENT_PRIVATE_KEY=
-# STRANGER_PRIVATE_KEY=
-# THRESHOLD_SPIKE_RUN=
+# --- Flutter build (--dart-define, public by design) ---
+# true = the standalone Canary web app that is deployed.
+NOSUS_CANARY_ONLY=true
+# Repo link shown in the app.
+CANARY_REPO_URL=https://github.com/https-shubhamsahu/nosus-canary
+
+# --- tool/solana-spike (optional, local only) ---
+# The spike writes its own devnet key to tool/solana-spike/.keys/ (git-ignored).
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 473051f..6383c44 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -1,8 +1 @@
-# Replace the handle if the GitHub repository uses a different owner account.
* @https-shubhamsahu
-
-/contracts/ @https-shubhamsahu
-/web/ @https-shubhamsahu
-/supabase/ @https-shubhamsahu
-/.github/ @https-shubhamsahu
-/docs/ @https-shubhamsahu
diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml
deleted file mode 100644
index d91d95d..0000000
--- a/.github/workflows/checks.yml
+++ /dev/null
@@ -1,34 +0,0 @@
-name: repository checks
-
-on:
- pull_request:
- push:
- branches: [main, "codex/**"]
-
-permissions:
- contents: read
-
-jobs:
- check:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v5
- - uses: actions/setup-node@v5
- with:
- node-version: 22
- cache: npm
- cache-dependency-path: |
- contracts/package-lock.json
- web/package-lock.json
- - name: Check repository navigation
- run: node tool/check-repository.mjs
- - name: Check contracts
- working-directory: contracts
- run: |
- npm ci
- npm test
- - name: Check web types
- working-directory: web
- run: |
- npm ci
- npm run check
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..6445938
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,78 @@
+name: CI
+
+on:
+ pull_request:
+ push:
+ branches: [main]
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ci-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ flutter:
+ name: Flutter app
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: app
+ steps:
+ - uses: actions/checkout@v5
+ - uses: subosito/flutter-action@v2
+ with:
+ channel: stable
+ flutter-version: 3.47.7
+ cache: true
+ - run: flutter pub get
+ - name: Analyze
+ run: flutter analyze
+ - name: Test
+ run: flutter test
+ - name: Fingerprint harness (must print FAILURES 0)
+ run: dart run tool/canary_fingerprint_harness.dart | tail -4
+
+ receipts-dart:
+ name: Receipts library (Dart)
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: packages/receipts-dart
+ steps:
+ - uses: actions/checkout@v5
+ - uses: subosito/flutter-action@v2
+ with:
+ channel: stable
+ flutter-version: 3.47.7
+ cache: true
+ - run: dart pub get
+ - run: dart analyze --fatal-infos
+ - run: dart format --output=none --set-exit-if-changed .
+ - run: dart test
+
+ deno:
+ name: Relayer + receipts library (TypeScript)
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v5
+ - uses: denoland/setup-deno@v2
+ with:
+ deno-version: v2.9.x
+ - name: Receipts library checks
+ working-directory: packages/receipts-ts
+ run: |
+ deno check mod.ts cli.ts test/
+ deno lint
+ deno fmt --check
+ deno test
+ - name: Relayer checks
+ working-directory: app/supabase/functions/canary
+ run: |
+ deno check index.ts
+ deno test --allow-env
+ - name: Relayer bundles for deploy
+ run: deno bundle --platform=deno app/supabase/functions/canary/index.ts -o "$RUNNER_TEMP/canary.bundle.js"
+ - name: Devnet spike type-checks
+ run: deno check tool/solana-spike/send_memo.ts
diff --git a/.gitignore b/.gitignore
index 3ef2b6d..720e43e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,3 +23,11 @@ Thumbs.db
.idea/
.vscode/
*.log
+
+# Flutter experiment and hosting metadata
+app/.dart_tool/
+app/.flutter-plugins-dependencies
+app/build/
+**/.vercel/
+app/supabase/.temp/
+*.local.json
diff --git a/AGENTS.md b/AGENTS.md
index 550d333..d732cc9 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,47 +1,45 @@
-# NO SUS — Monad Experiment agent map
-
-This repository is an isolated, **testnet-only** experiment. Read
-[docs/STATUS.md](docs/STATUS.md), then only the local guide for files you will change.
-
-## Product boundaries
-
-- Brand it **NO SUS — Monad Experiment**. Do not call it Rasid and do not change the production
- NO SUS repository, credentials, Supabase project, deployment, or URL.
-- Use only harmless, personally owned test notes. Never use credentials, third-party documents,
- employment records, financial information, or sensitive personal data.
-- The product may say "threshold-gated decryption after a wallet acknowledgement." It must never
- claim legal proof, a human read, screenshot prevention, serverless deletion, or identity proof.
-- The contract must never receive plaintext, encryption keys, salts, filenames, or document content.
-- Threshold custody is a release gate, not decoration: if the Monad-to-Lit proof is not clean, do
- not present it as shipped and never substitute a hidden server key-release path.
-
-## How to navigate
-
-| Area | Read first |
-|---|---|
-| Contract | `contracts/README.md` and `contracts/AGENTS.md` |
-| Web app | `web/README.md` and `web/AGENTS.md` |
-| Storage schema | `supabase/README.md` |
-| Handover | `docs/CURSOR_HANDOFF.md` |
-| Lit technical gate | `docs/LIT_MONAD_SPIKE.md` |
-| Parallel work | `docs/AGENT_COLLABORATION.md` |
-
-## Working rules
-
-- Use the smallest necessary change. Do not add an abstraction, dependency, code generator, or
- generic utility without a current consumer.
-- Keep all secrets in ignored environment files. `NEXT_PUBLIC_*` values are public by design; never
- put service-role keys, private keys, Lit account keys, or test-wallet keys there.
-- Work in a dedicated branch and small commits. Update `docs/STATUS.md` and local README files when
- behavior, contracts, or setup changes.
-- Normal work uses focused checks and localhost. Do not deploy, fund wallets, apply a Supabase
- migration, or use a real test note without explicit user direction.
-- After each substantive response that changes files, ask exactly: "Would you like to finalize today's
- work? If yes, say whether to verify only, build a target, or deploy a named target."
-
-Would you like to finalize today's work? If yes, say whether to verify only, build a target, or deploy a named target.
-
-## Finalization
-
-"Verify only" permits the contract tests, web checks, repository checks, and the real Monad/Lit
-technical gate. A deployment additionally needs an explicitly named target.
+# NO SUS Canary: agent map
+
+Read [docs/STATUS.md](docs/STATUS.md) first, then only the guide for the files you will change.
+
+## What this is
+
+NO SUS Canary: every reader of a note gets a uniquely marked copy, and every seal and every open is
+one SPL Memo transaction on **Solana devnet**, sent by a relayer (Supabase Edge Function `canary`)
+so readers need no wallet. Leak matching runs on the sender's device.
+
+## Map
+
+| Area | Code | Read first |
+|---|---|---|
+| Canary UI and fingerprinting | `app/lib/features/canary/` | `app/AGENTS.md` |
+| Relayer | `app/supabase/functions/canary/` | `docs/ARCHITECTURE.md` |
+| Receipt format and libraries | `packages/receipts-ts/`, `packages/receipts-dart/`, `packages/test-vectors/` | `docs/RECEIPTS_SPEC.md` |
+| Schema | `app/supabase/migrations/2026091909*`, `2026101009*` | `docs/SELF_HOSTING.md` |
+| Security model | — | `SECURITY.md` |
+
+## Rules
+
+- Backend: experiment project `ffidvfguojalzpclipzi` only. Never touch the NO SUS production
+ project, its credentials or deployment.
+- Never put note text, keys, names or device ids on chain. Memos carry hashes only; the format is
+ frozen at v1 (see the spec) and both libraries must stay byte-identical with the test vectors.
+- Secrets live in Supabase function secrets or git-ignored files (`.env.example` lists them). The
+ Supabase publishable key in `app/lib/config/supabase_credentials.dart` is public by design.
+- Keep the legacy EVM prototype path off: `CANARY_MONAD` stays false in Flutter and
+ `CANARY_CONTRACT_ADDRESS` stays unset for the function. User-facing text mentions Solana only.
+- Never claim screenshot prevention, legal proof, identity proof or proof that a human read a note.
+- Use harmless test notes only.
+- Smallest change that works. No new dependency or abstraction without a current consumer.
+- Owner approval is needed every time for: commits and pushes, merges, deploying the function,
+ applying SQL, setting secrets, funding keys or spending devnet SOL, changing gh-pages, and
+ making anything public.
+
+## Checks (CI runs all of these)
+
+```bash
+(cd app && flutter analyze && flutter test && dart run tool/canary_fingerprint_harness.dart)
+(cd app/supabase/functions/canary && deno check index.ts && deno test --allow-env)
+(cd packages/receipts-ts && deno check mod.ts cli.ts && deno lint && deno fmt --check && deno test)
+(cd packages/receipts-dart && dart pub get && dart analyze && dart format --set-exit-if-changed . && dart test)
+```
diff --git a/CLAUDE.md b/CLAUDE.md
index edc3f27..3085c49 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,3 +1,3 @@
-# NO SUS — Monad Experiment
+# NO SUS Canary
Read [AGENTS.md](AGENTS.md). It is the single source of truth for humans and coding agents.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..11069ed
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+END OF TERMS AND CONDITIONS
+
+APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+Copyright [yyyy] [name of copyright owner]
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
diff --git a/NOTICE b/NOTICE
new file mode 100644
index 0000000..1f01b77
--- /dev/null
+++ b/NOTICE
@@ -0,0 +1,8 @@
+NO SUS Canary
+Copyright 2026 Shubham Sahu (NO SUS)
+
+Licensed under the Apache License, Version 2.0 (see LICENSE).
+
+Third-party packages listed in app/pubspec.lock, packages/receipts-dart/pubspec.lock and
+packages/receipts-ts/deno.lock keep their own licenses. The fonts in app/assets/google_fonts/
+are from Google Fonts and keep their own (SIL Open Font) licenses.
diff --git a/README.md b/README.md
index ca7c358..e981e96 100644
--- a/README.md
+++ b/README.md
@@ -1,26 +1,219 @@
-# NO SUS — Monad Experiment
+
+
+
-An isolated Monad-testnet experiment for one-time encrypted notes. A recipient wallet may decrypt
-only after its acknowledgement is recorded on-chain and the threshold-custody gate accepts it.
+
+
+
+
+
+
-This is not the production NO SUS application. It has a separate Git repository, Supabase project,
-deployment, credentials, and product decision process.
+**NO SUS Canary lets you share a sensitive note with a group so that every reader gets a secretly
+different copy, and every seal and every open is recorded as a Solana transaction. If a screenshot
+leaks, NO SUS tells you whose copy it was.**
-## Start here
+[**Live demo**](https://shubham-sahu.me/nosus-canary/) ·
+[Pitch video (2:24)](https://github.com/https-shubhamsahu/nosus-canary/releases/download/v0.1.0/nosus-canary-pitch.mp4) ·
+[Demo video (1:18)](https://github.com/https-shubhamsahu/nosus-canary/releases/download/v0.1.0/nosus-canary-demo.mp4) ·
+[Judge FAQ](SUBMISSION.md) ·
+[A real receipt on Solana Explorer](https://explorer.solana.com/tx/5TXFPfTDFWyVe98DVPph7r55uRdJAcih6vKMBBRJKA96KtWT7Q4MUoFQG6GwH8N1FFkiMMbZzPssGtqtoW3wAsy2?cluster=devnet) ·
+[Security model](SECURITY.md)
-1. Read [AGENTS.md](AGENTS.md) and [docs/CURSOR_HANDOFF.md](docs/CURSOR_HANDOFF.md).
-2. Complete the threshold-gate technical spike before marketing or demo claims.
-3. Use only harmless, personally owned test notes on Monad testnet.
+
+
+
+Recorded on the live site on 20 Oct 2026, no edits to the app.
+Receipts from this run:
+seal ·
+Priya's open ·
+Ben's open.
+MP4 version.
-## Layout
+## The problem
-- `web/` — Next.js recipient, creator, verifier, and live-wall experience.
-- `contracts/` — Solidity contract and local test suite.
-- `supabase/` — isolated storage metadata schema; do not apply it to NO SUS.
-- `docs/` — security model, status, setup, and Cursor continuation materials.
+Salary sheets, offer letters, investor decks, exam papers and internal memos leak as screenshots
+and forwards. When they do, nobody can show whose copy it was. Name watermarks get cropped.
+Document-security tools ask every reader to make an account or install an app, so people go back
+to WhatsApp and email.
-## Honest product statement
+## The insight
-The on-chain receipt proves that a wallet acknowledged a committed encrypted drop. It does not prove
-human comprehension, real-world identity, screenshot prevention, legal admissibility, or independently
-verifiable deletion of remote storage.
+Give every reader a slightly different copy (a "canary trap"), and make the record of who got
+which copy something neither side can edit.
+
+- **Small wording changes survive.** `do not` / `don't`, `5` / `five`, `until` / `till`, plus an
+ invisible marker. They survive copy-paste, retyping into a chat and OCR, so even part of a
+ leaked text points to one copy.
+- **Receipts are now cheap and fast.** A Solana memo costs about 0.000005 SOL and confirms in about
+ a second. That makes one neutral, timestamped receipt per reader affordable. A row in our own
+ database is only our word.
+
+## The product
+
+| | What happens | Where it runs |
+|---|---|---|
+| **1. Write once** | You write the note. Canary makes up to 100 copies that read the same but are each unique, and encrypts them. A **seal** memo goes to Solana with one hash of all copies. | Your browser (AES-256, key only in the link's `#fragment`) + relayer |
+| **2. Share one link** | Each reader types a name and gets the next unused copy. An **open** memo goes to Solana with an anonymous tag. **Readers need no wallet, no app and no sign-up.** | Reader's browser + relayer |
+| **3. Catch a leak** | Paste the leaked text (or part of it). Canary names the copy, or names nobody if too little survived. | Your device only |
+
+
+
+  |
+  |
+  |
+
+
+ | Each reader gets their own copy |
+ A pasted leak names the copy |
+ Every open is a memo on Solana |
+
+
+
+## Traction
+
+We are early: live, pre-revenue, devnet only.
+
+- **Canary on Solana devnet:** live at [shubham-sahu.me/nosus-canary](https://shubham-sahu.me/nosus-canary/)
+ since 10 Oct 2026. The relayer has written **31 receipts** so far (20 Oct 2026), all from our own
+ tests and demos. Every one is public on the
+ [relayer's account page](https://explorer.solana.com/address/CCD4SUQt4No2QNidpCuNAxJZXsqY1KhxkpfkuphueQJx?cluster=devnet).
+- **Parent product, [NO SUS](https://nosus.foo):** a privacy app for notes and files that open once
+ and are watermarked with the viewer's name. In production since July 2026 (Google Play closed
+ test, APK and web), 10+ releases. On 10 Oct 2026: 18 registered users, 6 active in the last 30
+ days.
+
+## Why Solana
+
+1. **Cost per open.** A receipt is one SPL Memo instruction with one signature, so it pays the
+ 5,000-lamport base fee (0.000005 SOL). One receipt per reader stays affordable even for a
+ 100-reader note.
+2. **Speed.** Receipts confirm in about a second, so the sender sees "opened" while the reader is
+ still reading. In the recording above, the sealed link was ready about 3 s after clicking
+ Create, including encryption and confirmation.
+3. **Neutral and append-only.** Once written, neither the sender, the reader nor NO SUS can edit
+ a receipt. Anyone can check it on Solana Explorer without trusting our database.
+4. **No program to audit, no wallet to install.** We use the standard SPL Memo program, which
+ Explorer shows as plain text. The relayer pays fees, so readers never see a wallet.
+5. **Room to scale.** Batching many opens into one transaction (Merkle roots, state compression)
+ and the Solana dApp Store for the Android app are both on the roadmap below.
+
+## Architecture
+
+```mermaid
+flowchart LR
+ subgraph S["Sender's device"]
+ W["Write note"] --> K["Make N marked copies
word swaps + invisible marker"]
+ K --> E["Encrypt each copy
AES-256, key stays in the link #fragment"]
+ L["Leaked text"] --> M["Match on device
names a copy or nobody"]
+ end
+ E -- "ciphertexts + one hash of all copies" --> F["Relayer
Supabase Edge Function 'canary'"]
+ RD["Reader's browser
no wallet"] -- "open + typed name" --> F
+ F -- "their ciphertext only" --> RD
+ F -- "seal memo / open memo
hashed ids only" --> SOL[("Solana devnet
SPL Memo program")]
+ SOL -- "signature" --> X["Solana Explorer link
on every proof chip"]
+ F <--> DB[("Postgres
ciphertexts, names, signatures")]
+```
+
+| Where | What it holds | What it never holds |
+|---|---|---|
+| Solana (public) | Hashed note id, copy count, one hash of all copies, expiry, copy number, salted reader tag | Note text, keys, names, device ids |
+| Server (Supabase) | Encrypted copies, reader names and open times (shown to the sender), signatures | Note text, the decryption key |
+| Sender's device | The note, every copy's fingerprint, the owner secret | — |
+| The link | Note id and the AES key, in the `#fragment` (browsers never send it to a server) | — |
+
+More: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and the receipt format in
+[docs/RECEIPTS_SPEC.md](docs/RECEIPTS_SPEC.md).
+
+## Receipts you can check now
+
+| Receipt | Signature | Memo |
+|---|---|---|
+| Seal, 10 copies (20 Oct 2026) | [`QqdiqT84…uL11wb`](https://explorer.solana.com/tx/QqdiqT84UfFyHNhpfLrK32HfqbUB5vF8qtC4DiAQ7wZZGn2MkUVJGGpVPpgmk4MCxVLtkhmZm6rbuBxqyuL11wb?cluster=devnet) | `{"p":"nosus-canary","v":1,"t":"seal","note":"0x6b0b…3698","copies":10,"hash":"0x…","exp":…}` |
+| Open, copy 1 (Priya) | [`5TXFPfTD…oW3wAsy2`](https://explorer.solana.com/tx/5TXFPfTDFWyVe98DVPph7r55uRdJAcih6vKMBBRJKA96KtWT7Q4MUoFQG6GwH8N1FFkiMMbZzPssGtqtoW3wAsy2?cluster=devnet) | `{"p":"nosus-canary","v":1,"t":"open","note":"0x6b0b…3698","copy":0,"tag":"0x4096…c2b0"}` |
+| Open, copy 2 (Ben) | [`3tqhYUQ2…h7GMTcT1`](https://explorer.solana.com/tx/3tqhYUQ2BppvWh6dy8q5u7U23NpZ7FqfAnd34B58GTriGVZXtykcPj1i4sF7bNh4t8ShnhQokySNz7Uwh7GMTcT1?cluster=devnet) | same note, `"copy":1` |
+| First seal ever (10 Oct 2026) | [`2BHqnuCx…FRk512bQ`](https://explorer.solana.com/tx/2BHqnuCxYyemfegHkncgTsQyMPjpyB9boD1XqYfD1Wtbcf51Fpy3y264UwGhnvRKtcxFKCaSAzHxLrDwFRk512bQ?cluster=devnet) | `"t":"seal"`, 3 copies |
+| All receipts | [relayer `CCD4SUQt…hueQJx`](https://explorer.solana.com/address/CCD4SUQt4No2QNidpCuNAxJZXsqY1KhxkpfkuphueQJx?cluster=devnet) | |
+
+Decode any receipt yourself, without our app or server:
+
+```bash
+deno run --allow-net packages/receipts-ts/cli.ts 5TXFPfTDFWyVe98DVPph7r55uRdJAcih6vKMBBRJKA96KtWT7Q4MUoFQG6GwH8N1FFkiMMbZzPssGtqtoW3wAsy2
+```
+
+## Run it locally in under 5 minutes
+
+You need [Flutter](https://docs.flutter.dev/get-started/install) 3.47+ and Chrome. The app talks
+to the hosted devnet backend with its public (publishable) key, so there is nothing else to set up.
+
+```bash
+git clone https://github.com/https-shubhamsahu/nosus-canary.git
+cd nosus-canary/app
+flutter pub get
+flutter run -d chrome --dart-define=NOSUS_CANARY_ONLY=true
+```
+
+Run the checks that CI runs (add [Deno](https://deno.com) 2.x for the last three):
+
+```bash
+(cd app && flutter analyze && flutter test)
+(cd packages/receipts-dart && dart pub get && dart analyze && dart test)
+(cd packages/receipts-ts && deno test)
+(cd app/supabase/functions/canary && deno check index.ts && deno test --allow-env)
+```
+
+Your own backend and relayer: [docs/SELF_HOSTING.md](docs/SELF_HOSTING.md). Secrets template:
+[.env.example](.env.example).
+
+## Security notes
+
+- **On chain:** only sha256-based ids and hashes. The reader tag is salted with a server secret
+ and bound to the note and copy, so tags can't be linked across notes. Never note text, keys,
+ names or device ids.
+- **On the server:** encrypted copies it cannot read (the key never leaves the link fragment),
+ plus each reader's typed name and open time, which the sender sees.
+- **On the sender's device:** the original note and the fingerprint of every copy. Leak matching
+ runs here. Clearing browser data deletes it.
+- **What a receipt proves:** that the NO SUS relayer recorded this seal or this open at this time,
+ and that the note's copies were committed before anyone read them. It does **not** prove who a
+ person is, that a human read the text, or who shared a leak. Canary names *whose copy* leaked.
+- **Known limits:** it does not stop screenshots; a reader who rewrites the text in their own
+ words can defeat it; two readers who blend their copies can confuse the match (measured rates
+ in the threat model). Encryption is AES-256 in CTR mode without an authentication tag today;
+ moving to AES-GCM is on the roadmap.
+- **Secrets:** none in the repo. The whole history (46 commits, all branches) was scanned on
+ 20 Oct 2026; the only key found is the Supabase publishable key, which is public by design.
+
+Full threat model and how to report a problem: [SECURITY.md](SECURITY.md).
+
+## Roadmap
+
+| Next | What | Status |
+|---|---|---|
+| **Mainnet** | Receipts on Solana mainnet-beta with priority fees, retries with backoff, and relayer key rotation | Devnet live |
+| **Open-source receipts library** | `@nosus/receipts` (TypeScript) and `nosus_receipts` (Dart): encode, parse, send, fetch and verify Canary receipts, with shared test vectors | **v0.1 in [`packages/`](packages/)**; the live relayer runs it; not yet published to JSR, npm or pub.dev |
+| **Solana dApp Store / Seeker** | Canary inside the NO SUS Android app, submitted to the Solana dApp Store | Android build exists; not submitted |
+| **PDFs and images** | Per-copy marks for files, not only text | Not started |
+| **Merkle / compressed batching** | Many opens in one transaction (a Merkle root per batch, or state compression) to cut cost at scale | Not started |
+| **Authenticated encryption** | AES-GCM per copy, so a tampered copy fails to open | Not started |
+
+## What's in this repo
+
+| Path | What |
+|---|---|
+| [`app/`](app/) | The NO SUS Flutter app (web + Android). Canary lives in [`app/lib/features/canary/`](app/lib/features/canary/); the deployed site is the standalone build (`NOSUS_CANARY_ONLY=true`). |
+| [`app/supabase/functions/canary/`](app/supabase/functions/canary/) | The relayer: one Supabase Edge Function (Deno) that stores ciphertexts and writes the Solana memos |
+| [`app/supabase/migrations/`](app/supabase/migrations/) | Database schema, including `canary_notes` and `canary_copies` |
+| [`packages/receipts-ts/`](packages/receipts-ts/) · [`packages/receipts-dart/`](packages/receipts-dart/) | The receipts library, v0.1, plus [shared test vectors](packages/test-vectors/) from real devnet transactions |
+| [`docs/`](docs/) | Architecture, receipt spec, self-hosting, status and verification log |
+| [`tool/solana-spike/`](tool/solana-spike/) | Script that sends a test seal and open memo to devnet with your own key |
+
+## Team
+
+**Shubham Sahu**, founder of NO SUS. B.E. Electronics & Computer Engineering, TSEC Mumbai (2028).
+[nosus.foo](https://nosus.foo) · [shubhamsahu@nosus.foo](mailto:shubhamsahu@nosus.foo) ·
+[LinkedIn](https://www.linkedin.com/in/shubhamsahu9372) · [X @isagi0011](https://x.com/isagi0011)
+
+## License
+
+[Apache-2.0](LICENSE). Use harmless test notes only on the devnet demo.
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..d93cef1
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,93 @@
+# Security
+
+## Reporting a problem
+
+Email **shubhamsahu@nosus.foo** with "security" in the subject. Please don't open a public issue
+for a vulnerability. The demo runs on Solana devnet and a test
+backend; never put real secrets or personal data into it.
+
+## What goes where
+
+| Data | Sender's device | Link | Server (Supabase) | Solana (public) |
+|---|---|---|---|---|
+| Note text and every copy | ✅ | — | encrypted only | — |
+| AES-256 key | ✅ | in the `#fragment` | — | — |
+| Fingerprint of each copy (which words were swapped) | ✅ | — | — | — |
+| Owner secret (unlocks the dashboard) | ✅ | — | stored as sha256 only | — |
+| Reader's typed name, open time | shown on dashboard | — | ✅ | — |
+| Reader's device id (random, per browser) | — | — | stored as sha256 only | — |
+| Note id | ✅ | ✅ | ✅ | sha256 only |
+| Commitment to all copies | ✅ | — | ✅ | ✅ |
+| Which copy was opened | ✅ | — | ✅ | ✅ |
+| Reader tag | — | — | ✅ | ✅ |
+
+The browser never sends the `#fragment` to a server, so the key reaches only people who have the
+link. The Canary tables have row-level security on and no grants to the public (`anon`) role; only
+the edge function, using the service role, can read or write them.
+
+## How each value is computed
+
+| Value | Formula | Computed by |
+|---|---|---|
+| Chain note id | `0x` + sha256(`"nosus-canary:" + noteId`) | device and relayer |
+| Copy digest | sha256(`salt + ":" + index + ":" + copyText`) with a random 32-byte salt kept on the device | device |
+| Copies hash (sealed on chain) | `0x` + sha256(all copy digests joined in copy order) | device |
+| Owner hash | sha256(`ownerSecret`), the secret is 32 random bytes kept on the device | device |
+| Device hash | sha256(`"device:" + deviceId`) | relayer |
+| Reader tag (on chain) | `0x` + sha256(`CANARY_TAG_SALT + ":" + noteId + ":" + copyIndex + ":" + deviceHash`) | relayer |
+
+`CANARY_TAG_SALT` is a 32-byte server secret. Without it nobody can link a tag to a device or link
+two tags from different notes. The memo format is specified in
+[docs/RECEIPTS_SPEC.md](docs/RECEIPTS_SPEC.md).
+
+## Threat model
+
+**What Canary is for:** naming which reader's copy a leaked text came from, and keeping a neutral,
+timestamped record of the seal and of each open.
+
+| Who | What they try | What happens |
+|---|---|---|
+| A reader | Screenshot or copy-paste their copy | The copy is identified if enough text survives. See the measured rates below. |
+| A reader | Strip invisible characters, change case or punctuation, OCR a screenshot | Still identified: the word swaps carry the fingerprint. |
+| A reader | Retype the note in their own words | Can defeat it. Canary marks wording, not meaning. |
+| Two readers | Blend their two copies | Usually names one of them; sometimes names an innocent reader (rates below). The app warns about this. |
+| Anyone with the link | Open a copy and type a fake name | Works. A typed name is not an identity. The link is the access control: share it like a password. |
+| Someone with the sender's device | Read the note, open the dashboard | Works. Device security is out of scope. |
+| A passive observer of Solana | Learn the note, names or devices | Sees only hashes, copy counts, copy numbers, expiry and timestamps. |
+| The server operator (us) | Read notes | Cannot: the server never has the key. |
+| The server operator (us) | Give two readers the same copy, swap ciphertexts, or skip a receipt | Possible today. Partly detectable: the seal commits to every copy before anyone reads, and the sender's device can check any copy a reader shows them against it. Authenticated encryption (AES-GCM) is on the roadmap so a tampered copy fails to open. |
+| Whoever steals the relayer key | Write fake memos from our relayer address | Possible. The key is a devnet key stored as a Supabase secret, never in the repo. Mainnet adds key rotation. |
+
+**Measured leak matching** (`app/tool/canary_fingerprint_harness.dart`, run in CI; a note with 15
+swappable words, like the app's demo note):
+
+| Leak | 20 copies | 50 copies | 100 copies |
+|---|---|---|---|
+| Full copy without invisible marker, OCR-style text, 2% typos | 300 / 300 each, 0 wrong | 300 / 300 each, 0 wrong | 300 / 300 each, 0 wrong |
+| Partial excerpts: named / wrong | 431 / 600, 0 wrong | 313 / 600, 0 wrong | 188 / 600, 0 wrong |
+| Two copies blended: an innocent named | 1 / 600 | 8 / 600 | 23 / 600 |
+
+## What we do not claim
+
+- Canary does not stop screenshots. It makes them traceable.
+- A receipt proves that the NO SUS relayer recorded an event at a time. It does not prove who a
+ person is, that a human read the text, or who shared a leak.
+- It is not legal evidence by itself.
+- Deleting data from the server is an operational step, not something anyone can verify.
+
+## Known gaps
+
+- **Encryption mode.** Copies use AES-256 from `package:encrypt` with its default mode (SIC, which
+ is CTR) and a random 16-byte IV per copy. There is no authentication tag. Planned: AES-GCM.
+- **Relayer trust.** One relayer key signs every memo. Planned: several keys with rotation, and
+ receipts batched under a Merkle root so a reader can check their own open.
+- **Devnet only.** Devnet can be reset; receipts there are for demos, not records.
+
+## Secrets in this repository
+
+None. On 20 Oct 2026 every commit on every branch (46 commits, 17 refs, plus unreachable objects)
+was scanned for Solana and EVM private keys, Supabase service-role and secret keys, GitHub, Google,
+AWS, Stripe and AI-provider tokens, PEM keys and signing files. The only key-like value is the
+Supabase **publishable** key in `app/lib/config/supabase_credentials.dart`, which is public by
+design and gated by row-level security. Real secrets live only in Supabase function secrets and in
+git-ignored files; see [.env.example](.env.example).
diff --git a/SUBMISSION.md b/SUBMISSION.md
new file mode 100644
index 0000000..722bd42
--- /dev/null
+++ b/SUBMISSION.md
@@ -0,0 +1,112 @@
+# NO SUS Canary: for judges and reviewers
+
+**One line:** share a sensitive note with a group; every reader gets a secretly different copy;
+every seal and every open is a Solana transaction; if a screenshot leaks, NO SUS names whose copy
+it was.
+
+| | Link |
+|---|---|
+| Live app (Solana devnet) | https://shubham-sahu.me/nosus-canary/ |
+| Code (Apache-2.0) | https://github.com/https-shubhamsahu/nosus-canary |
+| Pitch video (2:24) | [nosus-canary-pitch.mp4](https://github.com/https-shubhamsahu/nosus-canary/releases/download/v0.1.0/nosus-canary-pitch.mp4) |
+| Product demo, narrated (1:18) | [nosus-canary-demo.mp4](https://github.com/https-shubhamsahu/nosus-canary/releases/download/v0.1.0/nosus-canary-demo.mp4) |
+| 36-second silent demo | [GIF](docs/media/canary-demo.gif) · [MP4](docs/media/canary-demo.mp4) |
+| A real open receipt | [`5TXFPfTD…oW3wAsy2` on Solana Explorer](https://explorer.solana.com/tx/5TXFPfTDFWyVe98DVPph7r55uRdJAcih6vKMBBRJKA96KtWT7Q4MUoFQG6GwH8N1FFkiMMbZzPssGtqtoW3wAsy2?cluster=devnet) |
+| Every receipt so far | [Relayer account on Solana Explorer](https://explorer.solana.com/address/CCD4SUQt4No2QNidpCuNAxJZXsqY1KhxkpfkuphueQJx?cluster=devnet) |
+| Receipt format | [docs/RECEIPTS_SPEC.md](docs/RECEIPTS_SPEC.md) |
+| Threat model | [SECURITY.md](SECURITY.md) |
+| Contact | Shubham Sahu · [shubhamsahu@nosus.foo](mailto:shubhamsahu@nosus.foo) |
+
+## Check it yourself in 60 seconds
+
+1. Open the [live app](https://shubham-sahu.me/nosus-canary/) → **Create a Canary link** →
+ **Use the demo note** → **Create Canary link**. A "Sealed on Solana" chip appears.
+2. Open the link in a private window, type any name, tap **Open my copy**. Your copy shows a
+ "Recorded on Solana" chip. Click it: Solana Explorer shows one Memo instruction with hashes only.
+3. Copy the reader's text. Back in the first window, open **Who opened it** and paste it into
+ **Check a leak** → **Find the copy**. It names the reader.
+
+Use harmless text only. This is devnet.
+
+## 2-minute demo script
+
+| Time | Do | Say |
+|---|---|---|
+| 0:00 | Show the landing page | "Salary sheets, offer letters and decks leak as screenshots. Today nobody can show whose copy it was." |
+| 0:15 | **Create a Canary link** → **Use the demo note** | "I write the note once. Canary makes up to 100 copies that read the same, with tiny word swaps like *don't* and *do not*, and an invisible marker." |
+| 0:30 | Point at the fingerprint meter, pick 10 readers, **Create Canary link** | "Every copy is encrypted in my browser. The key stays in the link. Before anyone reads, a seal goes to Solana: one hash of all ten copies." |
+| 0:45 | Click **Sealed on Solana** → Explorer | "That's a real devnet transaction. One SPL Memo. No text, no names, just hashes." |
+| 1:00 | Open the link in a private window, type "Priya", **Open my copy** | "Priya needs no wallet and no app. She gets her own copy, and her open is another Solana memo, paid by our relayer for about 0.000005 SOL." |
+| 1:20 | Copy Priya's text. Sender window: **Who opened it**, paste into **Check a leak**, **Find the copy** | "Now imagine Priya's copy shows up in a group chat. I paste it in. Matching runs on my device." |
+| 1:35 | Result card: **The canary sang · Priya · Copy 1 / 10** | "It names her copy. If too little text survived, it names nobody. It never guesses." |
+| 1:45 | Click **Proof on Solana** | "And the receipt shows when her copy was opened, on a chain neither of us can edit." |
+| 1:55 | Back to README roadmap | "Next: mainnet, the open receipts library, PDFs, and the Solana dApp Store." |
+
+## FAQ
+
+**What does a receipt prove, exactly?**
+That the NO SUS relayer recorded a seal (with a commitment to every copy) or an open of copy N, at
+a chain timestamp, and that the copies were committed before anyone read them. It does not prove
+a person's identity, that a human read the text, or who shared a leak. Canary names *whose copy*
+leaked. Phones get lost and screens get shown to friends.
+
+**Why put this on a chain instead of in your database?**
+A database row is our word, and we can edit it. A Solana memo is public, timestamped and
+append-only, so the sender, the reader and a third party can all check the same record without
+trusting NO SUS.
+
+**Why Solana?**
+One receipt per reader has to be cheap (each receipt pays the 5,000-lamport base fee) and fast (about a
+second to confirm, so the sender sees "opened" right away). The standard SPL Memo program means
+there is no custom program to audit, and Explorer shows the memo as plain text.
+
+**Does the server see the note?**
+No. Copies are encrypted on the sender's device and the AES key lives in the link's `#fragment`,
+which browsers never send to a server. The server does see each reader's typed name and when they
+opened, because the sender's dashboard shows it. Names never go on chain.
+
+**Can a reader strip the marks?**
+Removing the invisible marker is not enough: the word swaps still identify the copy. From the
+fingerprint test harness (`app/tool/canary_fingerprint_harness.dart`, run in CI), on a note with
+15 swappable words like the app's demo note:
+
+| Leak | 20 copies | 50 copies | 100 copies |
+|---|---|---|---|
+| Full copy, invisible marker removed | 300 / 300 right | 300 / 300 | 300 / 300 |
+| OCR-style (caps, no punctuation) | 300 / 300 right | 300 / 300 | 300 / 300 |
+| 2% random typos | 300 / 300 right, 0 wrong | 300 / 300, 0 wrong | 300 / 300, 0 wrong |
+| Partial excerpts (named / wrong) | 431 / 600, 0 wrong | 313 / 600, 0 wrong | 188 / 600, 0 wrong |
+| Two readers blend their copies: an innocent named | 1 / 600 | 8 / 600 | 23 / 600 |
+
+When an excerpt is too short, Canary names nobody instead of guessing. A reader who rewrites the
+whole note in their own words can defeat it. Blending is the known weak spot; the app says so.
+
+**Who pays the fees? Do readers need a wallet?**
+The relayer (a Supabase Edge Function) pays and signs every memo. Readers never see a wallet.
+
+**Are reader tags private?**
+Each tag is `sha256(server salt : note id : copy number : sha256(device id))`. Without the server
+salt nobody can link tags across notes or back to a device.
+
+**Is it on mainnet?**
+Not yet. Devnet only. Mainnet with priority fees and retries is the first roadmap item.
+
+**What is open source?**
+Everything in this repo, under Apache-2.0: the Flutter app, the relayer, the database schema, and
+the receipts library v0.1 in TypeScript and Dart (`packages/`), which any app can use to write and
+verify the same receipts.
+
+**What is the business?**
+Pre-revenue. First users we are building for: small teams, HR, founders sharing decks, and college
+groups in India. Planned paid features for teams: leak alerts, a team dashboard and audit exports.
+None of those are built yet.
+
+**What traction is there?**
+Canary has been live on devnet since 10 Oct 2026; its relayer has written 31 receipts (20 Oct
+2026), all from our own tests and demos. The parent app, NO SUS, has been in production since July
+2026 with 18 registered users and 6 active in the last 30 days (10 Oct 2026). We do not claim more
+than that.
+
+**Who is building it?**
+Shubham Sahu, founder of NO SUS, B.E. Electronics & Computer Engineering at TSEC Mumbai (2028).
+NO SUS has shipped 10+ releases since July 2026.
diff --git a/app/.gitignore b/app/.gitignore
new file mode 100644
index 0000000..791c253
--- /dev/null
+++ b/app/.gitignore
@@ -0,0 +1,120 @@
+# === NO SUS — .gitignore ===
+
+# --- Secrets & Credentials ---
+.env
+.env.local
+.env.production
+.env.development
+*.pem
+*.key
+signing_keys.json
+
+# Firebase per-project config. This repo is public, so the file is injected in
+# CI from GOOGLE_SERVICES_JSON_BASE64 rather than checked in — same convention
+# as ANDROID_KEYSTORE_BASE64. android/app/build.gradle.kts applies the
+# google-services plugin only when the file exists, so a clone without it still
+# builds; it just ships without push.
+android/app/google-services.json
+ios/Runner/GoogleService-Info.plist
+
+# --- Supabase temp files ---
+supabase/.temp/
+supabase/.branches/
+
+# --- Flutter / Dart ---
+**/doc/api/
+.dart_tool/
+.pub-cache/
+.pub/
+**/build/
+coverage/
+.flutter-plugins-dependencies
+*.flutter-plugins
+
+# --- Rust (fhe-compute) ---
+# CRITICAL: target/ is the Rust build cache (~39GB). Never commit it.
+**/target/
+**/*.rs.bk
+
+# --- Release artifacts ---
+# Built APKs/bundles are large; keep them out of git (archive separately).
+releases/
+
+# --- Agent / editor tooling ---
+.agents/
+.cursor/
+
+# --- IDE ---
+.idea/
+.vscode/
+*.iml
+*.ipr
+*.iws
+*.swp
+*.swo
+*~
+
+# --- OS ---
+.DS_Store
+Thumbs.db
+*.log
+*.class
+*.pyc
+
+# --- Android ---
+/android/app/debug
+/android/app/profile
+/android/app/release
+/local.properties
+android/local.properties
+/key.properties
+*.jks
+*.keystore
+
+# --- iOS ---
+**/ios/Flutter/.last_build_id
+**/ios/Pods/
+**/ios/.symlinks/
+**/ios/Flutter/Flutter.framework
+**/ios/Flutter/Flutter.podspec
+**/ios/Runner.xcworkspace/
+*.xcworkspace
+
+# --- macOS ---
+**/macos/Pods/
+**/macos/.symlinks/
+
+# --- Windows ---
+**/windows/out/
+**/windows/runner/Debug/
+**/windows/runner/Release/
+*.vcxproj.user
+
+# --- Build outputs ---
+app.*.symbols
+app.*.map.json
+*.dill
+*.dill.map
+
+# --- Generated files ---
+*.g.dart
+*.freezed.dart
+*.mocks.dart
+
+# --- Temporary ---
+migrate_working_dir/
+.history
+.svn/
+.atom/
+.build/
+.buildlog/
+.swiftpm/
+
+# --- Node / Edge Functions ---
+supabase/functions/**/node_modules/
+supabase/functions/**/dist/
+
+*.lcov
+
+# Local marker for the AGENTS.md change-log hook (per-checkout state)
+.claude/.changelog-head
diff --git a/app/.metadata b/app/.metadata
new file mode 100644
index 0000000..1e65aef
--- /dev/null
+++ b/app/.metadata
@@ -0,0 +1,30 @@
+# This file tracks properties of this Flutter project.
+# Used by Flutter tool to assess capabilities and perform upgrades etc.
+#
+# This file should be version controlled and should not be manually edited.
+
+version:
+ revision: "924134a44c189315be2148659913dda1671cbe99"
+ channel: "stable"
+
+project_type: app
+
+# Tracks metadata for the flutter migrate command
+migration:
+ platforms:
+ - platform: root
+ create_revision: 924134a44c189315be2148659913dda1671cbe99
+ base_revision: 924134a44c189315be2148659913dda1671cbe99
+ - platform: android
+ create_revision: 924134a44c189315be2148659913dda1671cbe99
+ base_revision: 924134a44c189315be2148659913dda1671cbe99
+
+ # User provided section
+
+ # List of Local paths (relative to this file) that should be
+ # ignored by the migrate tool.
+ #
+ # Files that are not part of the templates will be ignored by default.
+ unmanaged_files:
+ - 'lib/main.dart'
+ - 'ios/Runner.xcodeproj/project.pbxproj'
diff --git a/app/AGENTS.md b/app/AGENTS.md
new file mode 100644
index 0000000..44bbea4
--- /dev/null
+++ b/app/AGENTS.md
@@ -0,0 +1,18 @@
+# NO SUS Canary: Flutter app
+
+This is the NO SUS Flutter app (copied from the production `no_sus` repo at
+`ec29fdae94a68fefcb5efea2797224dc2245549c`). NO SUS Canary lives in `lib/features/canary/`. The
+root `AGENTS.md` still applies.
+
+- The deployed site is the standalone Canary build: `--dart-define=NOSUS_CANARY_ONLY=true`, base
+ href `/nosus-canary/`. Without that flag you get the full NO SUS app, with Canary reachable from
+ the welcome screen and `#/canary`.
+- Backend: `ffidvfguojalzpclipzi` only. Never apply this fork's SQL to the production project. No
+ production users or storage were copied. The publishable Supabase key is a public client key.
+- Native source is kept for parity. Do not release it with the production application or signing
+ identity.
+- Leave `CANARY_MONAD` off (default). User-facing text names Solana only.
+- After changing the word list in `canary_fingerprint.dart`, run
+ `dart run tool/canary_fingerprint_harness.dart`; it must end with `FAILURES: 0`.
+- Run `flutter analyze` and `flutter test` before every commit. Do not reformat unrelated copied
+ files. Migrations are immutable; add new ones.
diff --git a/app/MASCOT_GUIDE.md b/app/MASCOT_GUIDE.md
new file mode 100644
index 0000000..01dd61e
--- /dev/null
+++ b/app/MASCOT_GUIDE.md
@@ -0,0 +1,65 @@
+# NO SUS — MASCOT GUIDE
+> Exclusive documentation for Lux and Nox — the emotional guides and silent security guards of NO SUS.
+
+---
+
+## 1. Mascot Profiles
+
+### Lux (Light Guide)
+* **Personality**: Curious, encouraging, observant.
+* **Role**: Primary UI navigator, study desk companion, and success celebrator. Lux helps users feel oriented and supported in their workspace tasks.
+* **Visual Theme**: Light-aligned monochrome, clean strokes.
+
+### Nox (Dark Guard)
+* **Personality**: Protective, alerts-driven, quiet.
+* **Role**: Visual guardian. Appears during high-security moments, security event notices, and when recipients open documents (e.g. notifications snackbars).
+* **Visual Theme**: Dark-aligned monochrome, sharp outlines.
+
+### Duo (The Composition)
+* **Role**: Reserved for scarce bookend moments, such as session logins and log summaries. The launch screen stays deliberately mascot-free so it can load quickly and remain focused.
+* **Visual Asset**: Falls back to `assets/icon/LuxandNox.png` when the `.riv` asset is absent.
+
+---
+
+## 2. Animation States & Moods
+The mascots use the Rive State Machine (`SM`) with a control input named `stateIndex` (mapping to the integer values of `MascotMood` below) and `reducedMotion` (boolean).
+
+| Mood Index | Mood Name | Rive Artboard Behavior | Trigger Condition / Usage |
+|---|---|---|---|
+| 0 | `idle` | Slow breathing loop | Default state |
+| 1 | `sleep` | Closed eyes, slow pacing | App inactive / idle timer |
+| 2 | `wake` | Eyes opening, small stretch | App resume |
+| 3 | `walk` | Moving legs in profile | Transition animations |
+| 4 | `lookAround` | Head turns left/right | WorkspaceTab initialization (played once on tab boot) |
+| 5 | `think` | Hand-to-chin, eyes upward | Running computation or search |
+| 6 | `observe` | Leaning forward, watching | Reading state or page scroll |
+| 7 | `guide` | Pointing gesture | Onboarding tutorials / tooltips |
+| 8 | `celebrate` | Small jump / wave | Upload completed, milestone achieved |
+| 9 | `wait` | Tapping foot | Async job in queue / pending state |
+| 10 | `sit` | Resting pose | Inactive on workspace desk |
+| 11 | `stretch` | Quick body extension | Long session transition |
+| 12 | `protect` | Arms crossed, alert posture | Private directory focus |
+| 13 | `approve` | Nods head | Validation checks pass |
+| 14 | `verify` | Adjusts glasses / lens lookup | Cryptographic chain validation success |
+| 15 | `guard` | Standing sentinel | RLS check active / Vault tab focus |
+| 16 | `alert` | Surprised expression, flash | Threat detected / screenshot block |
+| 17 | `stamp` | Overlaying action | Watermark successfully stamped on view |
+| 18 | `returnToLogo` | Fades out to logo mark | Transition to main navigation logo |
+
+---
+
+## 3. Motion Language & Interaction Rules
+1. **Never Interfere with Content**: Mascots must only occupy designated empty spaces or side containers (like headers, snackbars, and teaser card elements).
+2. **Reduced Motion Compliance**:
+ * If `MediaQuery.of(context).disableAnimations` is `true`, the `reducedMotion` input is set on the Rive artboard, forcing the mascot controller to lock visual states directly to `MascotMood.idle`.
+3. **Imperative Control**:
+ * Mascots are driven imperatively via Riverpod providers. Use `ref.read(luxMascotProvider.notifier).play(MascotMood.lookAround)` to trigger animations.
+
+---
+
+## 4. Design Constraints
+* **Format**: Rive `.riv` assets located in `assets/mascot/.riv`.
+* **Fallback Behavior**:
+ * `MascotView` uses a fallback widget placeholder before a `.riv` file is loaded to prevent visual breaks.
+ * Duo falls back to the static png `assets/icon/LuxandNox.png`.
+ * Solo Lux/Nox fallback defaults to `SizedBox.shrink()` (silent hidden state) or explicit icons (e.g. `Icon(Icons.notifications_active)` in share notifications).
diff --git a/app/PROJECT_CONSTITUTION.md b/app/PROJECT_CONSTITUTION.md
new file mode 100644
index 0000000..075b5ba
--- /dev/null
+++ b/app/PROJECT_CONSTITUTION.md
@@ -0,0 +1,65 @@
+# NO SUS — PROJECT CONSTITUTION
+> Authoritative, permanent guide on product vision, standards, and rules. This document does NOT track active developments or session logs.
+
+---
+
+## 1. Product Vision
+**One-Sentence Vision:**
+NO SUS lets people share a sensitive document, know when it was opened, and retain simple control over the link without forcing recipients to register or install anything.
+
+**Target Users:**
+1. **Students, professionals, and small teams**: Sharing documents that need context, visibility, or a revocable link.
+2. **Privacy-conscious communicators**: Exchanging one-time secrets (credentials, private messages) over a client-encrypted, self-destructing channel.
+
+---
+
+## 2. Founder Philosophy
+1. **Zero-Budget, Solo Leverage**: Optimize all architecture for a single developer. Maximize generous free tiers (Supabase, Deno, Cloudflare) and avoid operational overhead.
+2. **Viral-Growth Distribution**: To access a SecureSend document or open a Burn Note, the recipient must interact with the platform. The product drives its own distribution.
+3. **Cryptographic & Technical Honesty**: Copy and brand claims must never outrun the actual cryptography. Do not claim screenshot-proofing on browsers or total server blindness until client-side key generation (M10) is built.
+
+---
+
+## 3. Version 1 Scope
+V1 focuses on shipping a highly stable, low-maintenance, polished web-first document link security tool:
+* **SecureSend**: Share links with expiration dates, maximum view counts, touch-to-reveal blur, dynamic recipient-email watermarks, and real-time view logs.
+* **Burn Notes**: Client-side AES-256 encrypted short text notes with keys kept in the URL hash fragment (zero-knowledge) and destroyed atomically on read.
+
+---
+
+## 4. Long-Term Vision
+* **Optional document assistance**: A future Gemini or on-device adapter may help an authorized user summarize or understand a document. It is always optional, feature-flagged, and cannot block core sharing.
+
+---
+
+## 5. Engineering & Security Philosophy
+* **RLS-First Security**: Every table must have Row-Level Security enabled. RLS policies must prevent data leaks even if client applications are fully compromised.
+* **Server Boundary**: The mobile/web client must never receive server-side credentials or communicate directly with external cloud storage. Sensitive server operations go through Supabase Edge Functions.
+* **Client-Side Secret Ownership**: For zero-knowledge features, the keys must be generated, stored, and used entirely in the client browser. No key material may ever touch the server database or logs.
+* **Shipping Over Perfection**: Focus on production readiness, simplicity, and maintainability over unnecessary optimizations or architectural complexity.
+
+---
+
+## 6. UI Philosophy
+* **Monochrome Paper-Ink Identity**: Clean, distraction-free near-black/near-white visuals. Use `NoSusTheme` spacing, radii, and color tokens.
+* **Typography**: Outfit for display/headings (bold, large letter-spacing), Inter for body/labels (high readability).
+* **Frictionless Onboarding**: Skip gates, magic links, or OTP recovery should guide users into utility as fast as possible.
+
+---
+
+## 7. Coding Standards & Conventions
+* **Clean Architecture**: Core and new features must follow strict separation of layers:
+ * `Domain`: Pure Dart interfaces, entities, and business logic.
+ * `Data`: Supabase implementations, local/network data sources, and models.
+ * `Presentation`: Riverpod state providers, controllers, and UI widgets.
+* **Service Singletons**: Legacy or auxiliary features may use simple singleton helper services (`SupabaseService`, `AuditService`, `ScreenshotGuard`) injected via Riverpod providers.
+* **No Duplicate Migrations**: SQL migrations must be incremental, idempotent, and fully reversible.
+* **Remove Dead Code**: Clean up unused imports, deprecated assets, and unreferenced functions after edits.
+
+---
+
+## 8. Non-Negotiable Rules
+1. **Never hardcode secrets** (must use `.env` / `--dart-define-from-file`).
+2. **Never bypass repositories** in presentation code.
+3. **Never allow unauthenticated INSERTs** into system logs or tables except where explicitly designed (e.g. Burn Notes insert, which is encrypted client-side).
+4. **No v2/v3 duplicate files** or split branch iterations.
diff --git a/app/analysis_options.yaml b/app/analysis_options.yaml
new file mode 100644
index 0000000..ec9fbef
--- /dev/null
+++ b/app/analysis_options.yaml
@@ -0,0 +1,40 @@
+# This file configures the analyzer, which statically analyzes Dart code to
+# check for errors, warnings, and lints.
+#
+# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
+# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
+# invoked from the command line by running `flutter analyze`.
+
+# The following line activates a set of recommended lints for Flutter apps,
+# packages, and plugins designed to encourage good coding practices.
+analyzer:
+ exclude:
+ - build/**
+ - android/**
+ - ios/**
+ - web/**
+ - windows/**
+ - macos/**
+ - linux/**
+include: package:flutter_lints/flutter.yaml
+
+linter:
+ # The lint rules applied to this project can be customized in the
+ # section below to disable rules from the `package:flutter_lints/flutter.yaml`
+ # included above or to enable additional rules. A list of all available lints
+ # and their documentation is published at https://dart.dev/lints.
+ #
+ # Instead of disabling a lint rule for the entire project in the
+ # section below, it can also be suppressed for a single line of code
+ # or a specific dart file by using the `// ignore: name_of_lint` and
+ # `// ignore_for_file: name_of_lint` syntax on the line or in the file
+ # producing the lint.
+ rules:
+ # avoid_print: false # Uncomment to disable the `avoid_print` rule
+ # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
+ prefer_const_constructors: true
+ prefer_const_constructors_in_immutables: true
+ prefer_const_literals_to_create_immutables: true
+
+# Additional information about this file can be found at
+# https://dart.dev/guides/language/analysis-options
diff --git a/app/android/.gitignore b/app/android/.gitignore
new file mode 100644
index 0000000..be3943c
--- /dev/null
+++ b/app/android/.gitignore
@@ -0,0 +1,14 @@
+gradle-wrapper.jar
+/.gradle
+/captures/
+/gradlew
+/gradlew.bat
+/local.properties
+GeneratedPluginRegistrant.java
+.cxx/
+
+# Remember to never publicly share your keystore.
+# See https://flutter.dev/to/reference-keystore
+key.properties
+**/*.keystore
+**/*.jks
diff --git a/app/android/app/build.gradle.kts b/app/android/app/build.gradle.kts
new file mode 100644
index 0000000..6c8a703
--- /dev/null
+++ b/app/android/app/build.gradle.kts
@@ -0,0 +1,143 @@
+import java.util.Properties
+
+plugins {
+ id("com.android.application")
+ // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
+ id("dev.flutter.flutter-gradle-plugin")
+}
+
+// Firebase Cloud Messaging, applied only when the project has actually been
+// provisioned. The google-services plugin fails the build with "File
+// google-services.json is missing" if it is applied without one, so putting it
+// in the plugins {} block above would break every build in CI and on every
+// fresh clone — the repo has no such file and must not (it is per-project
+// config, and checking one in would tie the open repo to one Firebase project).
+//
+// Drop the file in android/app/ and this wires itself up on the next build.
+// Nothing else needs to change. See AGENTS.md §8.
+if (file("google-services.json").exists()) {
+ apply(plugin = "com.google.gms.google-services")
+}
+
+// Repo-root .env, the same file CI writes and `--dart-define-from-file=.env`
+// reads. Git-ignored and usually absent, in which case this stays empty and
+// every lookup below falls back to "". rootProject here is android/, so the
+// repo root is one level up.
+val measureEnv = Properties().apply {
+ val envFile = rootProject.file("../.env")
+ if (envFile.exists()) {
+ envFile.inputStream().use { load(it) }
+ }
+}
+
+android {
+ namespace = "foo.nosus.app"
+ compileSdk = flutter.compileSdkVersion
+ ndkVersion = flutter.ndkVersion
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ defaultConfig {
+ // Separate app id so this build installs next to the production
+ // NO SUS app and never uses its Play identity. namespace stays
+ // foo.nosus.app because the Kotlin sources live in that package.
+ applicationId = "foo.nosus.canary"
+ minSdk = flutter.minSdkVersion // flutter_secure_storage requires API 23+
+ targetSdk = flutter.targetSdkVersion
+ versionCode = flutter.versionCode
+ versionName = flutter.versionName
+
+ // Measure (measure.sh) credentials for AndroidManifest.xml.
+ //
+ // The native SDK initialises in Application.onCreate, before the
+ // Flutter engine exists, so it cannot be handed a --dart-define. It
+ // reads meta-data from the manifest instead. Sourcing both sides from
+ // the one repo-root .env is what keeps the native gate and the Dart
+ // gate (lib/config/measure_reporting_config.dart, fed by
+ // --dart-define-from-file=.env) in agreement.
+ //
+ // Empty when .env is absent — a fresh clone and a CI run without the
+ // secrets both build fine and ship the SDK inert. Placeholders must
+ // always be set to *something*: an unresolved ${...} in the manifest
+ // is a merge failure, not a warning.
+ manifestPlaceholders["measureApiKey"] = measureEnv.getProperty("MEASURE_API_KEY") ?: ""
+ manifestPlaceholders["measureApiUrl"] = measureEnv.getProperty("MEASURE_API_URL") ?: ""
+ }
+
+ signingConfigs {
+ create("release") {
+ val keystorePropertiesFile = rootProject.file("key.properties")
+ val keystoreProperties = Properties()
+ if (keystorePropertiesFile.exists()) {
+ keystorePropertiesFile.inputStream().use { stream ->
+ keystoreProperties.load(stream)
+ }
+ // A present-but-blank key.properties (e.g. checked out fresh,
+ // template never filled in) must be treated the same as a
+ // missing file. Properties.getProperty("storeFile") on a line
+ // like "storeFile=" returns "" (empty string), which is not
+ // null — file("") resolves to the project directory itself,
+ // a non-null File that would otherwise slip past the
+ // `storeFile != null` check below and get selected as a
+ // broken "release" signing config instead of falling back
+ // to debug signing.
+ val storeFilePath = keystoreProperties.getProperty("storeFile")
+ if (!storeFilePath.isNullOrBlank()) {
+ keyAlias = keystoreProperties.getProperty("keyAlias")
+ keyPassword = keystoreProperties.getProperty("keyPassword")
+ storeFile = file(storeFilePath)
+ storePassword = keystoreProperties.getProperty("storePassword")
+ }
+ }
+ }
+ }
+
+ buildTypes {
+ release {
+ val releaseConfig = signingConfigs.findByName("release")
+ if (releaseConfig != null && releaseConfig.storeFile != null && releaseConfig.storeFile!!.exists()) {
+ signingConfig = releaseConfig
+ } else {
+ signingConfig = signingConfigs.getByName("debug")
+ }
+ // Enable R8 minification and resource shrinking with custom rules
+ isMinifyEnabled = true
+ isShrinkResources = true
+ proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+ // Required for pdfrx native PDF renderer — AGP 8+ equivalent of extractNativeLibs="true"
+ packaging {
+ jniLibs {
+ useLegacyPackaging = true
+ }
+ }
+ }
+ }
+}
+
+kotlin {
+ compilerOptions {
+ jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
+ }
+}
+
+flutter {
+ source = "../.."
+}
+
+dependencies {
+ // Modern Android 12+ SplashScreen API, backported to minSdk via the
+ // compat shim so pre-12 devices get equivalent behavior through the
+ // same windowSplashScreen* theme attributes (see styles.xml).
+ implementation("androidx.core:core-splashscreen:1.0.1")
+
+ // Play Integrity API (standard request) — server-verifiable device/app
+ // attestation, complementing the client-side heuristics in
+ // RootDetector.kt/InstrumentationDetector.kt (which a sufficiently
+ // capable attacker can spoof; Play Integrity's response is signed by
+ // Google and verified server-side in supabase/functions/verify-play-integrity).
+ // See PlayIntegrityManager.kt for why this is scaffolded, not enabled.
+ implementation("com.google.android.play:integrity:1.4.0")
+}
diff --git a/app/android/app/proguard-rules.pro b/app/android/app/proguard-rules.pro
new file mode 100644
index 0000000..9641948
--- /dev/null
+++ b/app/android/app/proguard-rules.pro
@@ -0,0 +1,52 @@
+# Custom ProGuard / R8 Rules for NO SUS
+# Applied in release builds via build.gradle.kts
+
+# ─── Flutter Engine ────────────────────────────────────────────────────────────
+-keep class io.flutter.app.** { *; }
+-keep class io.flutter.plugin.** { *; }
+-keep class io.flutter.util.** { *; }
+-keep class io.flutter.view.** { *; }
+-keep class io.flutter.embedding.** { *; }
+-keep class io.flutter.plugins.** { *; }
+
+# ─── Platform Channel Plugins ──────────────────────────────────────────────────
+-keep class com.it_club.flutter_secure_storage.** { *; }
+-keep class dk.itst.android.security.** { *; }
+-keep class com.mr.flutter.filepicker.** { *; }
+-keep class com.protect.screen_protector.** { *; }
+
+# ─── pdfrx (native PDF renderer — JNI bridge must be preserved) ───────────────
+-keep class dev.majrie.pdfrx.** { *; }
+-keep class com.pdfrx.** { *; }
+-keepclassmembers class * {
+ @android.webkit.JavascriptInterface ;
+}
+
+# ─── Supabase / Ktor / OkHttp (WebSocket + HTTP client) ──────────────────────
+-keep class io.github.jan.supabase.** { *; }
+-keep class io.ktor.** { *; }
+-keep class okhttp3.** { *; }
+-keep interface okhttp3.** { *; }
+-dontwarn okhttp3.**
+-dontwarn io.ktor.**
+
+# ─── JSON / Serialization ─────────────────────────────────────────────────────
+-keep class com.google.gson.** { *; }
+-dontwarn com.google.gson.**
+
+# Keep Kotlin serialization metadata (used by Supabase Kotlin client)
+-keepattributes *Annotation*
+-keepattributes Signature
+-keepattributes InnerClasses,EnclosingMethod
+
+# ─── Suppress known harmless warnings ─────────────────────────────────────────
+-dontwarn com.google.android.play.core.**
+-dontwarn android.support.**
+-dontwarn androidx.**
+
+# --- NO SUS Canary: ML Kit text recognition (Latin model only) ---
+# The plugin references optional script recognizers that are not bundled.
+-dontwarn com.google.mlkit.vision.text.chinese.**
+-dontwarn com.google.mlkit.vision.text.devanagari.**
+-dontwarn com.google.mlkit.vision.text.japanese.**
+-dontwarn com.google.mlkit.vision.text.korean.**
diff --git a/app/android/app/src/debug/AndroidManifest.xml b/app/android/app/src/debug/AndroidManifest.xml
new file mode 100644
index 0000000..399f698
--- /dev/null
+++ b/app/android/app/src/debug/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..bc30f6b
--- /dev/null
+++ b/app/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,169 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/android/app/src/main/kotlin/foo/nosus/app/MainActivity.kt b/app/android/app/src/main/kotlin/foo/nosus/app/MainActivity.kt
new file mode 100644
index 0000000..a351405
--- /dev/null
+++ b/app/android/app/src/main/kotlin/foo/nosus/app/MainActivity.kt
@@ -0,0 +1,373 @@
+package foo.nosus.app
+
+import android.app.Activity
+import android.content.Intent
+import android.database.ContentObserver
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.provider.MediaStore
+import android.view.MotionEvent
+import android.view.WindowManager
+import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
+import io.flutter.embedding.android.FlutterActivity
+import io.flutter.embedding.engine.FlutterEngine
+import io.flutter.plugin.common.EventChannel
+import io.flutter.plugin.common.MethodChannel
+import foo.nosus.app.security.DeviceIntegrityManager
+import foo.nosus.app.security.KeyAttestationManager
+import foo.nosus.app.security.PlayIntegrityManager
+import java.io.File
+import java.io.FileOutputStream
+
+class MainActivity : FlutterActivity() {
+ private val SECURITY_CHANNEL = "co.nosus.app/security"
+ private val SCREENSHOT_CHANNEL = "co.nosus.app/screenshot"
+ private val SHARE_CHANNEL = "co.nosus.app/share"
+ private val INTEGRITY_CHANNEL = "co.nosus.app/device_integrity"
+ private val PLAY_INTEGRITY_CHANNEL = "co.nosus.app/play_integrity"
+
+ private var screenshotObserver: ContentObserver? = null
+ private var screenCaptureCallback: Activity.ScreenCaptureCallback? = null
+ private var eventSink: EventChannel.EventSink? = null
+ private var methodChannel: MethodChannel? = null
+ private var sharedData: Map? = null
+ private var overlayEventCoolingDown = false
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ // Must be called before super.onCreate() — installs the AndroidX
+ // SplashScreen compat shim (styles.xml's windowSplashScreen*
+ // attributes) so it's active on API < 31 too, and hands off cleanly
+ // to the real platform SplashScreen on API 31+. Kept up only until
+ // Flutter's first frame; lib/screens/splash_screen.dart's
+ // VideoSplashScreen owns everything the user actually watches.
+ installSplashScreen()
+ super.onCreate(savedInstanceState)
+ // Block screenshots and screen recordings — content appears black
+ window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
+ if (savedInstanceState == null) {
+ handleIntent(intent)
+ }
+ registerModernScreenshotCallback()
+ // Must exist before the first push arrives — Android drops a
+ // notification addressed to an unknown channel without any error.
+ // Idempotent and permission-free, so it runs unconditionally.
+ NotificationChannels.register(applicationContext)
+ }
+
+ /**
+ * Android 14+ fires this synchronously the moment a screenshot is taken,
+ * system-wide — far more reliable than the MediaStore heuristic below,
+ * which only notices *after* the file lands on disk. Older OS versions
+ * fall back to the ContentObserver path started in configureFlutterEngine.
+ */
+ private fun registerModernScreenshotCallback() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ try {
+ val callback = Activity.ScreenCaptureCallback {
+ eventSink?.success(mapOf("type" to "screenshot", "source" to "system_api"))
+ }
+ registerScreenCaptureCallback(mainExecutor, callback)
+ screenCaptureCallback = callback
+ } catch (_: Exception) {
+ // OEM skin variance — silently rely on the ContentObserver fallback.
+ }
+ }
+ }
+
+ private fun unregisterModernScreenshotCallback() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ screenCaptureCallback?.let {
+ try {
+ unregisterScreenCaptureCallback(it)
+ } catch (_: Exception) { }
+ }
+ }
+ }
+
+ /**
+ * Detects tapjacking/overlay attacks: the system sets these MotionEvent
+ * flags whenever a touch was delivered through another app's window
+ * overlaying ours (SYSTEM_ALERT_WINDOW). Requires
+ * android:filterTouchesWhenObscured="true" on the activity (set in
+ * AndroidManifest.xml) to actually be populated by the framework.
+ */
+ override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
+ val obscured = (ev.flags and MotionEvent.FLAG_WINDOW_IS_OBSCURED) != 0 ||
+ (ev.flags and MotionEvent.FLAG_WINDOW_IS_PARTIALLY_OBSCURED) != 0
+ if (obscured && !overlayEventCoolingDown) {
+ overlayEventCoolingDown = true
+ eventSink?.success(mapOf("type" to "overlay_detected"))
+ Handler(Looper.getMainLooper()).postDelayed({ overlayEventCoolingDown = false }, 3000)
+ }
+ return super.dispatchTouchEvent(ev)
+ }
+
+ override fun onNewIntent(intent: Intent) {
+ setIntent(intent)
+ super.onNewIntent(intent)
+ handleIntent(intent)
+ sendSharedDataToFlutter()
+ }
+
+ override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
+ super.configureFlutterEngine(flutterEngine)
+
+ methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, SHARE_CHANNEL)
+ methodChannel?.setMethodCallHandler { call, result ->
+ when (call.method) {
+ "getInitialShare" -> {
+ result.success(sharedData)
+ sharedData = null
+ }
+ "openUrl" -> {
+ val url = call.argument("url")
+ if (url != null) {
+ try {
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ startActivity(intent)
+ result.success(true)
+ } catch (e: Exception) {
+ result.error("FAILED", e.message, null)
+ }
+ } else {
+ result.error("BAD_ARGS", "Missing url parameter", null)
+ }
+ }
+ else -> result.notImplemented()
+ }
+ }
+
+ // ── MethodChannel: toggle FLAG_SECURE from Dart ──
+ MethodChannel(flutterEngine.dartExecutor.binaryMessenger, SECURITY_CHANNEL)
+ .setMethodCallHandler { call, result ->
+ when (call.method) {
+ "enableSecure" -> {
+ window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
+ result.success(true)
+ }
+ "disableSecure" -> {
+ window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
+ result.success(true)
+ }
+ else -> result.notImplemented()
+ }
+ }
+
+ // ── MethodChannel: on-demand device-integrity scan (root / Frida / Xposed / display mirroring / accessibility) ──
+ MethodChannel(flutterEngine.dartExecutor.binaryMessenger, INTEGRITY_CHANNEL)
+ .setMethodCallHandler { call, result ->
+ when (call.method) {
+ "runChecks" -> {
+ try {
+ result.success(DeviceIntegrityManager.runAllChecks(applicationContext))
+ } catch (e: Exception) {
+ result.error("INTEGRITY_SCAN_FAILED", e.message, null)
+ }
+ }
+ // Hardware-backed device identity. Shares this channel
+ // rather than opening a new one — same subsystem, same
+ // Dart-side service consumes both.
+ "getDeviceKeyId" -> {
+ try {
+ result.success(KeyAttestationManager.getOrCreateDeviceKeyId())
+ } catch (e: Exception) {
+ result.error("DEVICE_KEY_FAILED", e.message, null)
+ }
+ }
+ else -> result.notImplemented()
+ }
+ }
+
+ // ── MethodChannel: Play Integrity standard token request (scaffolded,
+ // not enabled — see PlayIntegrityManager.kt) ──
+ MethodChannel(flutterEngine.dartExecutor.binaryMessenger, PLAY_INTEGRITY_CHANNEL)
+ .setMethodCallHandler { call, result ->
+ when (call.method) {
+ "requestToken" -> {
+ val nonce = call.argument("nonce")
+ if (nonce == null) {
+ result.error("BAD_ARGS", "Missing nonce parameter", null)
+ } else {
+ PlayIntegrityManager.requestToken(applicationContext, nonce, result)
+ }
+ }
+ else -> result.notImplemented()
+ }
+ }
+
+ // ── EventChannel: push screenshot/recording/overlay events to Dart ──
+ EventChannel(flutterEngine.dartExecutor.binaryMessenger, SCREENSHOT_CHANNEL)
+ .setStreamHandler(object : EventChannel.StreamHandler {
+ override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
+ eventSink = events
+ startScreenshotObserver()
+ }
+
+ override fun onCancel(arguments: Any?) {
+ stopScreenshotObserver()
+ eventSink = null
+ }
+ })
+ }
+
+ private fun startScreenshotObserver() {
+ val handler = Handler(Looper.getMainLooper())
+
+ screenshotObserver = object : ContentObserver(handler) {
+ override fun onChange(selfChange: Boolean, uri: Uri?) {
+ uri ?: return
+ try {
+ // Resolve the file path to check if it's a screenshot/recording
+ val cursor = contentResolver.query(
+ uri,
+ arrayOf(MediaStore.MediaColumns.DATA, MediaStore.MediaColumns.DISPLAY_NAME),
+ null, null, null
+ )
+ cursor?.use {
+ if (it.moveToFirst()) {
+ val pathIndex = it.getColumnIndex(MediaStore.MediaColumns.DATA)
+ val nameIndex = it.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME)
+ val path = if (pathIndex >= 0) it.getString(pathIndex)?.lowercase() ?: "" else ""
+ val name = if (nameIndex >= 0) it.getString(nameIndex)?.lowercase() ?: "" else ""
+
+ val isScreenshot = path.contains("screenshot") || name.contains("screenshot")
+ val isRecording = path.contains("screenrecord") || name.contains("screenrecord") ||
+ path.contains("screen_record") || name.contains("screen_record")
+
+ if (isScreenshot || isRecording) {
+ eventSink?.success(mapOf(
+ "type" to if (isRecording) "recording" else "screenshot",
+ "source" to "media_store"
+ ))
+ }
+ }
+ }
+ } catch (_: Exception) { /* Ignore permission or cursor errors */ }
+ }
+ }
+
+ // Watch Images (screenshots) and Videos (screen recordings)
+ contentResolver.registerContentObserver(
+ MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true, screenshotObserver!!
+ )
+ contentResolver.registerContentObserver(
+ MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true, screenshotObserver!!
+ )
+ }
+
+ private fun stopScreenshotObserver() {
+ screenshotObserver?.let { contentResolver.unregisterContentObserver(it) }
+ screenshotObserver = null
+ }
+
+ override fun onDestroy() {
+ stopScreenshotObserver()
+ unregisterModernScreenshotCallback()
+ super.onDestroy()
+ }
+
+ private fun handleIntent(intent: Intent?) {
+ if (intent == null) return
+ val action = intent.action
+ val type = intent.type
+
+ if ((Intent.ACTION_SEND == action || Intent.ACTION_SEND_MULTIPLE == action) && type != null) {
+ if (type.startsWith("text/")) {
+ val text = intent.getStringExtra(Intent.EXTRA_TEXT)
+ ?: intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString()
+ if (text != null) {
+ val trimmed = text.trim()
+ val isUrl = trimmed.startsWith("http://") || trimmed.startsWith("https://")
+ sharedData = mapOf(
+ "type" to if (isUrl) "url" else "text",
+ "data" to trimmed,
+ "name" to null
+ )
+ }
+ } else {
+ val uris = if (Intent.ACTION_SEND_MULTIPLE == action) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)
+ } else {
+ @Suppress("DEPRECATION")
+ intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)
+ }
+ } else {
+ val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
+ } else {
+ @Suppress("DEPRECATION")
+ intent.getParcelableExtra(Intent.EXTRA_STREAM)
+ }
+ if (uri != null) arrayListOf(uri) else null
+ }
+
+ val uri = uris?.firstOrNull()
+ if (uri != null) {
+ val resolved = saveUriToCache(uri, type)
+ if (resolved != null) {
+ sharedData = mapOf(
+ "type" to if (type.startsWith("image/")) "image" else "pdf",
+ "data" to resolved.absolutePath,
+ "name" to resolved.name
+ )
+ }
+ }
+ }
+ }
+ }
+
+ private fun saveUriToCache(uri: Uri, mimeType: String): File? {
+ try {
+ var fileName = "shared_file_${System.currentTimeMillis()}"
+ val extension = when {
+ mimeType.startsWith("image/") -> {
+ val ext = mimeType.substringAfter("/", "")
+ if (ext.isNotEmpty()) ext else "png"
+ }
+ mimeType == "application/pdf" -> "pdf"
+ else -> "bin"
+ }
+
+ val projection = arrayOf(MediaStore.MediaColumns.DISPLAY_NAME)
+ contentResolver.query(uri, projection, null, null, null)?.use { cursor ->
+ if (cursor.moveToFirst()) {
+ val index = cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME)
+ if (index >= 0) {
+ val name = cursor.getString(index)
+ if (!name.isNullOrEmpty()) {
+ fileName = name
+ }
+ }
+ }
+ }
+
+ if (!fileName.contains(".")) {
+ fileName = "$fileName.$extension"
+ }
+
+ val cacheFile = File(cacheDir, fileName)
+ contentResolver.openInputStream(uri)?.use { inputStream ->
+ FileOutputStream(cacheFile).use { outputStream ->
+ inputStream.copyTo(outputStream)
+ }
+ }
+ return cacheFile
+ } catch (e: Exception) {
+ // Deliberately swallowed, not logged — printStackTrace() writes
+ // to stderr/logcat unconditionally, including in release builds.
+ return null
+ }
+ }
+
+ private fun sendSharedDataToFlutter() {
+ sharedData?.let {
+ methodChannel?.invokeMethod("onShareReceived", it)
+ sharedData = null
+ }
+ }
+}
diff --git a/app/android/app/src/main/kotlin/foo/nosus/app/NoSusApplication.kt b/app/android/app/src/main/kotlin/foo/nosus/app/NoSusApplication.kt
new file mode 100644
index 0000000..58c257e
--- /dev/null
+++ b/app/android/app/src/main/kotlin/foo/nosus/app/NoSusApplication.kt
@@ -0,0 +1,98 @@
+package foo.nosus.app
+
+import android.app.Application
+import android.content.pm.PackageManager
+import android.os.Build
+import sh.measure.android.Measure
+import sh.measure.android.config.MeasureConfig
+
+/**
+ * Exists for exactly one reason: the Measure SDK has no Dart-side
+ * initialisation path.
+ *
+ * `measure_flutter`'s MethodChannel exposes `start`/`stop`/`trackEvent`/… but
+ * no `init` — the native SDK must already be running by the time Dart calls
+ * anything, and it has to come up in `Application.onCreate` to catch crashes
+ * that happen before the Flutter engine exists. That is why this class is
+ * registered as `android:name` in AndroidManifest.xml instead of the
+ * `${applicationName}` placeholder Flutter fills in by default.
+ *
+ * It extends plain [Application]: this app is on Flutter's v2 embedding
+ * (`flutterEmbedding` = 2 in the manifest), which does not require
+ * `FlutterApplication`.
+ */
+class NoSusApplication : Application() {
+ override fun onCreate() {
+ super.onCreate()
+ initMeasureIfConfigured()
+ }
+
+ /**
+ * No-op unless both `MEASURE_API_KEY` and `MEASURE_API_URL` reached the
+ * manifest. They are injected as manifest placeholders by
+ * `android/app/build.gradle.kts`, read out of the repo-root `.env` — the
+ * same file `--dart-define-from-file` feeds to
+ * `lib/config/measure_reporting_config.dart`. A build without that `.env`
+ * (fresh clone, CI without the secrets) gets empty strings here and the SDK
+ * never starts.
+ *
+ * Guarding on the value rather than letting the SDK read the meta-data
+ * itself is deliberate: an unconfigured `Measure.init` would still spin up
+ * the SDK's storage and its periodic export machinery pointed at nothing.
+ */
+ private fun initMeasureIfConfigured() {
+ try {
+ val metaData = applicationMetaData() ?: return
+ val apiKey = metaData.getString(META_API_KEY).orEmpty()
+ val apiUrl = metaData.getString(META_API_URL).orEmpty()
+ if (apiKey.isBlank() || apiUrl.isBlank()) return
+
+ Measure.init(
+ this,
+ MeasureConfig(
+ // Initialise, but collect nothing until Dart explicitly
+ // calls start() — see main.dart. This is the single control
+ // point for collection, and the place a user-consent gate
+ // would attach. With the default (true) the native SDK
+ // would already be recording during the anonymous
+ // burn-note / share-viewer entry points, which never reach
+ // the normal app bootstrap at all.
+ autoStart = false,
+ // Load-bearing, despite matching the SDK default. Every
+ // link this app mints carries its AES key in the URL
+ // fragment (#/burn/?k=&v=), and those arrive
+ // as Activity intent data. Flipping this to true would
+ // upload plaintext decryption keys to a third party. Do
+ // not remove this line on the grounds that it is
+ // redundant — it is pinning a default that must never
+ // drift. See AGENTS.md §5.
+ trackActivityIntentData = false,
+ ),
+ )
+ } catch (_: Exception) {
+ // Monitoring must never be the reason the app fails to launch.
+ // Deliberately not logged: printStackTrace() writes to logcat
+ // unconditionally, including in release builds (same reasoning as
+ // MainActivity.saveUriToCache).
+ }
+ }
+
+ private fun applicationMetaData() = try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ packageManager.getApplicationInfo(
+ packageName,
+ PackageManager.ApplicationInfoFlags.of(PackageManager.GET_META_DATA.toLong()),
+ ).metaData
+ } else {
+ @Suppress("DEPRECATION")
+ packageManager.getApplicationInfo(packageName, PackageManager.GET_META_DATA).metaData
+ }
+ } catch (_: PackageManager.NameNotFoundException) {
+ null
+ }
+
+ private companion object {
+ const val META_API_KEY = "sh.measure.android.API_KEY"
+ const val META_API_URL = "sh.measure.android.API_URL"
+ }
+}
diff --git a/app/android/app/src/main/kotlin/foo/nosus/app/NotificationChannels.kt b/app/android/app/src/main/kotlin/foo/nosus/app/NotificationChannels.kt
new file mode 100644
index 0000000..1405bfb
--- /dev/null
+++ b/app/android/app/src/main/kotlin/foo/nosus/app/NotificationChannels.kt
@@ -0,0 +1,94 @@
+package foo.nosus.app
+
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.content.Context
+import android.os.Build
+
+/**
+ * Registers the notification channels that `supabase/functions/send-push`
+ * addresses by id.
+ *
+ * This is load-bearing, not cosmetic. On Android 8+ a notification whose
+ * `channel_id` does not exist on the device is **dropped silently** — no error,
+ * no fallback, nothing on screen. send-push sets
+ * `android.notification.channel_id` to `nosus_`, so without these
+ * every push would vanish and look like a broken FCM setup.
+ *
+ * Done in Kotlin rather than by adding flutter_local_notifications: channel
+ * creation is a dozen lines of platform API, and the plugin would be a new
+ * dependency carrying a whole local-notification stack this app does not
+ * otherwise use.
+ *
+ * Creating channels needs no permission and is safe with no Firebase project
+ * configured — the channels simply sit unused. Re-creating an existing channel
+ * is a no-op, so calling this on every launch is correct and lets a later
+ * release add a channel without any migration step.
+ *
+ * Note: name and importance are only honoured on first creation. Android hands
+ * channel settings to the user after that, deliberately, and an app cannot take
+ * them back. Renaming a channel means shipping a new id.
+ */
+object NotificationChannels {
+
+ /** Must match the `category` CHECK constraint on `public.notifications`. */
+ private val CHANNELS = listOf(
+ ChannelSpec(
+ id = "nosus_security",
+ name = "Security alerts",
+ description = "Access revoked, unusual sign-ins, and integrity warnings.",
+ importance = NotificationManager.IMPORTANCE_HIGH,
+ ),
+ ChannelSpec(
+ id = "nosus_invites",
+ name = "Invites and requests",
+ description = "Group invitations and requests to join groups you administer.",
+ importance = NotificationManager.IMPORTANCE_DEFAULT,
+ ),
+ ChannelSpec(
+ id = "nosus_membership",
+ name = "Membership and roles",
+ description = "Changes to your groups and your role in them.",
+ importance = NotificationManager.IMPORTANCE_DEFAULT,
+ ),
+ ChannelSpec(
+ id = "nosus_documents",
+ name = "Document activity",
+ description = "New documents shared in your groups.",
+ importance = NotificationManager.IMPORTANCE_LOW,
+ ),
+ // Backstop for the default channel declared in AndroidManifest.xml,
+ // which catches any message that arrives without a channel id.
+ ChannelSpec(
+ id = "nosus_general",
+ name = "General",
+ description = "Everything else.",
+ importance = NotificationManager.IMPORTANCE_DEFAULT,
+ ),
+ )
+
+ fun register(context: Context) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
+
+ val manager = context.getSystemService(NotificationManager::class.java) ?: return
+
+ for (spec in CHANNELS) {
+ val channel = NotificationChannel(spec.id, spec.name, spec.importance).apply {
+ description = spec.description
+ // Notification *content* already names no document and no group
+ // (see enqueue_notification), but setting this explicitly means
+ // a future payload change cannot start leaking onto a locked
+ // screen without someone also changing this line.
+ lockscreenVisibility = android.app.Notification.VISIBILITY_PRIVATE
+ }
+ manager.createNotificationChannel(channel)
+ }
+ }
+
+ private data class ChannelSpec(
+ val id: String,
+ val name: String,
+ val description: String,
+ val importance: Int,
+ )
+}
diff --git a/app/android/app/src/main/kotlin/foo/nosus/app/security/AccessibilityAuditor.kt b/app/android/app/src/main/kotlin/foo/nosus/app/security/AccessibilityAuditor.kt
new file mode 100644
index 0000000..3ed691a
--- /dev/null
+++ b/app/android/app/src/main/kotlin/foo/nosus/app/security/AccessibilityAuditor.kt
@@ -0,0 +1,39 @@
+package foo.nosus.app.security
+
+import android.accessibilityservice.AccessibilityServiceInfo
+import android.content.Context
+import android.view.accessibility.AccessibilityManager
+
+/**
+ * Flags enabled accessibility services outside a known-legitimate allowlist.
+ * Accessibility-service abuse (auto-clickers, overlay-based credential
+ * theft, remote-control trojans) is a well-documented Android attack
+ * pattern — a hit here does not prove malicious intent, it surfaces a
+ * service worth a user prompt or admin review.
+ */
+object AccessibilityAuditor {
+
+ private val KNOWN_SAFE_PREFIXES = arrayOf(
+ "com.google.android.marvin.talkback",
+ "com.google.android.accessibility",
+ "com.android.talkback",
+ "com.samsung.android.accessibility",
+ "com.samsung.accessibility"
+ )
+
+ fun scan(context: Context): Map {
+ val am = context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager
+ val enabled = am.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK)
+
+ val suspicious = enabled.mapNotNull { serviceInfo ->
+ val id = serviceInfo.id ?: return@mapNotNull null
+ val isKnownSafe = KNOWN_SAFE_PREFIXES.any { id.startsWith(it) }
+ if (isKnownSafe) null else id
+ }
+
+ return mapOf(
+ "suspiciousServicesFound" to suspicious.isNotEmpty(),
+ "services" to suspicious
+ )
+ }
+}
diff --git a/app/android/app/src/main/kotlin/foo/nosus/app/security/DeviceIntegrityManager.kt b/app/android/app/src/main/kotlin/foo/nosus/app/security/DeviceIntegrityManager.kt
new file mode 100644
index 0000000..a3e1ff5
--- /dev/null
+++ b/app/android/app/src/main/kotlin/foo/nosus/app/security/DeviceIntegrityManager.kt
@@ -0,0 +1,20 @@
+package foo.nosus.app.security
+
+import android.content.Context
+
+/**
+ * Aggregates the individual detectors into one payload the Dart-side
+ * MethodChannel handler in MainActivity returns as-is. Kept as a thin
+ * facade so MainActivity's platform-channel wiring doesn't need to know
+ * about each detector individually.
+ */
+object DeviceIntegrityManager {
+ fun runAllChecks(context: Context): Map {
+ return mapOf(
+ "root" to RootDetector.scan(context),
+ "instrumentation" to InstrumentationDetector.scan(context),
+ "displayMirroring" to DisplayMirrorDetector.scan(context),
+ "accessibility" to AccessibilityAuditor.scan(context)
+ )
+ }
+}
diff --git a/app/android/app/src/main/kotlin/foo/nosus/app/security/DisplayMirrorDetector.kt b/app/android/app/src/main/kotlin/foo/nosus/app/security/DisplayMirrorDetector.kt
new file mode 100644
index 0000000..577e432
--- /dev/null
+++ b/app/android/app/src/main/kotlin/foo/nosus/app/security/DisplayMirrorDetector.kt
@@ -0,0 +1,34 @@
+package foo.nosus.app.security
+
+import android.content.Context
+import android.hardware.display.DisplayManager
+import android.view.Display
+
+/**
+ * Flags additional displays (wired HDMI/DisplayPort, wireless
+ * Miracast mirroring, or a Cast "presentation" virtual display) beyond the
+ * device's own built-in screen. This is checked per viewing session (see
+ * DeviceIntegrityService.runViewingChecks) rather than once at launch,
+ * since mirroring can start after a document is already open.
+ */
+object DisplayMirrorDetector {
+
+ fun scan(context: Context): Map {
+ val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
+ val displays = displayManager.displays
+ val external = displays.filter { it.displayId != Display.DEFAULT_DISPLAY }
+
+ val info = external.map { display ->
+ mapOf(
+ "id" to display.displayId,
+ "name" to display.name,
+ "isPresentation" to ((display.flags and Display.FLAG_PRESENTATION) != 0)
+ )
+ }
+
+ return mapOf(
+ "mirroring" to external.isNotEmpty(),
+ "displays" to info
+ )
+ }
+}
diff --git a/app/android/app/src/main/kotlin/foo/nosus/app/security/InstrumentationDetector.kt b/app/android/app/src/main/kotlin/foo/nosus/app/security/InstrumentationDetector.kt
new file mode 100644
index 0000000..6497148
--- /dev/null
+++ b/app/android/app/src/main/kotlin/foo/nosus/app/security/InstrumentationDetector.kt
@@ -0,0 +1,81 @@
+package foo.nosus.app.security
+
+import android.content.Context
+import android.content.pm.PackageManager
+import android.os.Debug
+import java.io.File
+import java.net.InetSocketAddress
+import java.net.Socket
+
+/**
+ * Best-effort detection of dynamic instrumentation (Frida) and hook
+ * frameworks (Xposed/LSPosed). These are signals, not proof — LSPosed's
+ * per-app hide list and Frida's stealth/agent-injection modes can evade the
+ * file, port, and class checks below. Treated as "critical" severity
+ * upstream because, unlike root, active instrumentation implies someone is
+ * *right now* attached to this process, not merely that the device could be
+ * tampered with.
+ */
+object InstrumentationDetector {
+
+ private val XPOSED_PACKAGES = arrayOf(
+ "de.robv.android.xposed.installer",
+ "org.meowcat.edxposed.manager",
+ "io.github.lsposed.manager",
+ "org.lsposed.manager"
+ )
+
+ private val FRIDA_MAP_MARKERS = arrayOf("frida", "gum-js-loop", "linjector", "frida-agent")
+
+ fun scan(context: Context): Map {
+ val reasons = mutableListOf()
+
+ if (Debug.isDebuggerConnected() || Debug.waitingForDebugger()) {
+ reasons.add("debugger attached")
+ }
+
+ try {
+ Class.forName("de.robv.android.xposed.XposedBridge")
+ reasons.add("xposed bridge class loaded")
+ } catch (_: ClassNotFoundException) {
+ // Not present — expected when no hook framework is active.
+ }
+
+ val pm = context.packageManager
+ for (pkg in XPOSED_PACKAGES) {
+ try {
+ pm.getPackageInfo(pkg, 0)
+ reasons.add("hook framework manager installed: $pkg")
+ } catch (_: PackageManager.NameNotFoundException) {
+ // Not installed.
+ }
+ }
+
+ try {
+ val maps = File("/proc/self/maps").readLines()
+ val hasFridaMarker = maps.any { line ->
+ FRIDA_MAP_MARKERS.any { marker -> line.contains(marker, ignoreCase = true) }
+ }
+ if (hasFridaMarker) {
+ reasons.add("frida artifact in process memory map")
+ }
+ } catch (_: Exception) {
+ // SELinux may block reading our own maps on newer Android — this
+ // is inconclusive, not a false negative claim.
+ }
+
+ try {
+ Socket().use { socket ->
+ socket.connect(InetSocketAddress("127.0.0.1", 27042), 150)
+ reasons.add("frida-server listening on default port 27042")
+ }
+ } catch (_: Exception) {
+ // Closed/filtered — expected on clean devices.
+ }
+
+ return mapOf(
+ "instrumented" to reasons.isNotEmpty(),
+ "reasons" to reasons.distinct()
+ )
+ }
+}
diff --git a/app/android/app/src/main/kotlin/foo/nosus/app/security/KeyAttestationManager.kt b/app/android/app/src/main/kotlin/foo/nosus/app/security/KeyAttestationManager.kt
new file mode 100644
index 0000000..bd41dc2
--- /dev/null
+++ b/app/android/app/src/main/kotlin/foo/nosus/app/security/KeyAttestationManager.kt
@@ -0,0 +1,153 @@
+package foo.nosus.app.security
+
+import android.os.Build
+import android.security.keystore.KeyGenParameterSpec
+import android.security.keystore.KeyInfo
+import android.security.keystore.KeyProperties
+import java.security.KeyFactory
+import java.security.KeyPairGenerator
+import java.security.KeyStore
+import java.security.MessageDigest
+import java.security.PrivateKey
+import java.security.PublicKey
+import java.security.spec.ECGenParameterSpec
+
+/**
+ * Derives this install's device identity from a non-extractable EC keypair
+ * held in the Android Keystore, instead of a random UUID in SharedPreferences.
+ *
+ * Why: the previous identity was a plain `Uuid().v4()` written to prefs, which
+ * on a rooted device — exactly the population DeviceIntegrityService exists to
+ * notice — is editable in seconds. That made two detectors defeatable:
+ * a device flagged for root could rotate its id and come back clean, and the
+ * 'multiple_device_access' detector could be nulled out entirely by reporting
+ * one id from every device. The private key here never leaves the TEE (or
+ * StrongBox where available), so the id can't be forged or transplanted to
+ * another device; clearing app data destroys the key, which is correct.
+ *
+ * Scope note: this is the *local* half. The server still takes the client's
+ * word that the id came from hardware — proving that requires verifying an
+ * attestation certificate chain server-side, which needs a real physical
+ * device to test against and is deliberately not attempted here. See the
+ * "device identity" section of CLAUDE.md.
+ */
+object KeyAttestationManager {
+ private const val KEYSTORE_PROVIDER = "AndroidKeyStore"
+ private const val KEY_ALIAS = "nosus_device_identity_v1"
+
+ /**
+ * Returns the stable device id plus the security level the platform
+ * actually granted the key. Throws if the Keystore is unusable at all —
+ * the Dart side treats that as "no hardware id available" and falls back
+ * to the legacy UUID rather than failing the launch.
+ */
+ fun getOrCreateDeviceKeyId(): Map {
+ val keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply { load(null) }
+
+ val publicKey = existingPublicKey(keyStore) ?: generateKeyPair()
+ val level = securityLevelOf(keyStore)
+
+ return mapOf(
+ "deviceId" to sha256Hex(publicKey.encoded),
+ "securityLevel" to level,
+ "hardwareBacked" to (level != LEVEL_SOFTWARE)
+ )
+ }
+
+ /**
+ * An alias can survive in the keystore while its key material is gone or
+ * unreadable (OEM quirks, partial restores). Treat any failure to read it
+ * back as "no key" so the caller regenerates rather than throwing.
+ */
+ private fun existingPublicKey(keyStore: KeyStore): PublicKey? {
+ return try {
+ if (!keyStore.containsAlias(KEY_ALIAS)) return null
+ keyStore.getCertificate(KEY_ALIAS)?.publicKey
+ } catch (_: Exception) {
+ try {
+ keyStore.deleteEntry(KEY_ALIAS)
+ } catch (_: Exception) { }
+ null
+ }
+ }
+
+ /**
+ * StrongBox (a discrete secure element) is strictly better than the TEE
+ * but only exists on some devices, and OEMs have shipped implementations
+ * that throw variants beyond StrongBoxUnavailableException — hence the
+ * broad catch and unconditional retry without it.
+ */
+ private fun generateKeyPair(): PublicKey {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ try {
+ return generateWith(strongBox = true)
+ } catch (_: Exception) {
+ try {
+ KeyStore.getInstance(KEYSTORE_PROVIDER).apply { load(null) }.deleteEntry(KEY_ALIAS)
+ } catch (_: Exception) { }
+ }
+ }
+ return generateWith(strongBox = false)
+ }
+
+ private fun generateWith(strongBox: Boolean): PublicKey {
+ val generator = KeyPairGenerator.getInstance(
+ KeyProperties.KEY_ALGORITHM_EC,
+ KEYSTORE_PROVIDER
+ )
+
+ val builder = KeyGenParameterSpec.Builder(
+ KEY_ALIAS,
+ KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY
+ )
+ .setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1"))
+ .setDigests(KeyProperties.DIGEST_SHA256)
+
+ // Deliberately no setUserAuthenticationRequired: the id has to be
+ // readable at launch, before any biometric prompt would be shown, and
+ // this key signs nothing sensitive — it exists to *be* an identity.
+ if (strongBox && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ builder.setIsStrongBoxBacked(true)
+ }
+
+ generator.initialize(builder.build())
+ return generator.generateKeyPair().public
+ }
+
+ private fun securityLevelOf(keyStore: KeyStore): String {
+ return try {
+ val privateKey = keyStore.getKey(KEY_ALIAS, null) as? PrivateKey ?: return LEVEL_SOFTWARE
+ val keyInfo = KeyFactory
+ .getInstance(privateKey.algorithm, KEYSTORE_PROVIDER)
+ .getKeySpec(privateKey, KeyInfo::class.java)
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ when (keyInfo.securityLevel) {
+ KeyProperties.SECURITY_LEVEL_STRONGBOX -> LEVEL_STRONGBOX
+ KeyProperties.SECURITY_LEVEL_TRUSTED_ENVIRONMENT -> LEVEL_TEE
+ KeyProperties.SECURITY_LEVEL_SOFTWARE -> LEVEL_SOFTWARE
+ // UNKNOWN / UNKNOWN_SECURE: the platform is telling us it
+ // can't characterise the backing. Don't claim hardware.
+ else -> LEVEL_UNKNOWN
+ }
+ } else {
+ @Suppress("DEPRECATION")
+ if (keyInfo.isInsideSecureHardware) LEVEL_TEE else LEVEL_SOFTWARE
+ }
+ } catch (_: Exception) {
+ LEVEL_UNKNOWN
+ }
+ }
+
+ private fun sha256Hex(bytes: ByteArray): String {
+ val digest = MessageDigest.getInstance("SHA-256").digest(bytes)
+ val out = StringBuilder(digest.size * 2)
+ for (b in digest) out.append("%02x".format(b))
+ return out.toString()
+ }
+
+ private const val LEVEL_STRONGBOX = "strongbox"
+ private const val LEVEL_TEE = "tee"
+ private const val LEVEL_SOFTWARE = "software"
+ private const val LEVEL_UNKNOWN = "unknown"
+}
diff --git a/app/android/app/src/main/kotlin/foo/nosus/app/security/PlayIntegrityManager.kt b/app/android/app/src/main/kotlin/foo/nosus/app/security/PlayIntegrityManager.kt
new file mode 100644
index 0000000..bb97a82
--- /dev/null
+++ b/app/android/app/src/main/kotlin/foo/nosus/app/security/PlayIntegrityManager.kt
@@ -0,0 +1,50 @@
+package foo.nosus.app.security
+
+import android.content.Context
+import com.google.android.play.core.integrity.IntegrityManagerFactory
+import com.google.android.play.core.integrity.IntegrityTokenRequest
+import io.flutter.plugin.common.MethodChannel
+
+/**
+ * Requests a Play Integrity standard token — a Google-signed attestation of
+ * app/device/account integrity, verified server-side (never trust-on-read
+ * client-side) by supabase/functions/verify-play-integrity.
+ *
+ * SCAFFOLDING, NOT ENABLED: [cloudProjectNumber] must be replaced with the
+ * real Google Cloud project number linked to this app in Play Console
+ * (Play Console → App integrity → Play Integrity API → your project
+ * number) before any token request can succeed — 0 will always fail
+ * (Google rejects it). The Dart-side call site
+ * (lib/services/play_integrity_service.dart) is also gated behind
+ * AppIntegrityConfig.enabled, which defaults false, so this native code
+ * exists but is never invoked until BOTH are switched on. See CLAUDE.md's
+ * "Play Integrity — scaffolded, not enabled" note for the full setup path.
+ */
+object PlayIntegrityManager {
+ private const val cloudProjectNumber: Long = 0L
+
+ fun requestToken(context: Context, nonce: String, result: MethodChannel.Result) {
+ if (cloudProjectNumber == 0L) {
+ result.error(
+ "NOT_CONFIGURED",
+ "PlayIntegrityManager.cloudProjectNumber is unset — see the class doc.",
+ null,
+ )
+ return
+ }
+
+ val integrityManager = IntegrityManagerFactory.create(context)
+ val request = IntegrityTokenRequest.builder()
+ .setNonce(nonce)
+ .setCloudProjectNumber(cloudProjectNumber)
+ .build()
+
+ integrityManager.requestIntegrityToken(request)
+ .addOnSuccessListener { response ->
+ result.success(response.token())
+ }
+ .addOnFailureListener { exception ->
+ result.error("INTEGRITY_TOKEN_FAILED", exception.message, null)
+ }
+ }
+}
diff --git a/app/android/app/src/main/kotlin/foo/nosus/app/security/RootDetector.kt b/app/android/app/src/main/kotlin/foo/nosus/app/security/RootDetector.kt
new file mode 100644
index 0000000..4651fc8
--- /dev/null
+++ b/app/android/app/src/main/kotlin/foo/nosus/app/security/RootDetector.kt
@@ -0,0 +1,85 @@
+package foo.nosus.app.security
+
+import android.content.Context
+import android.content.pm.PackageManager
+import android.os.Build
+import java.io.File
+
+/**
+ * Heuristic root detection. No single check here is authoritative — a
+ * determined attacker with Magisk's DenyList/Zygisk hiding enabled can evade
+ * every file-path and package check below. These findings feed risk scoring
+ * as signals, not a hard gate: see DeviceIntegrityService (Dart side) for
+ * the "log, don't block" policy this project has chosen.
+ */
+object RootDetector {
+
+ private val ROOT_PATHS = arrayOf(
+ "/system/bin/su",
+ "/system/xbin/su",
+ "/sbin/su",
+ "/su/bin/su",
+ "/system/app/Superuser.apk",
+ "/system/app/SuperSU",
+ "/data/local/xbin/su",
+ "/data/local/bin/su",
+ "/system/sd/xbin/su",
+ "/system/bin/failsafe/su",
+ "/system/xbin/busybox",
+ "/data/local/su"
+ )
+
+ private val ROOT_PACKAGES = arrayOf(
+ "com.topjohnwu.magisk",
+ "eu.chainfire.supersu",
+ "com.noshufou.android.su",
+ "com.koushikdutta.superuser",
+ "com.thirdparty.superuser",
+ "com.yellowes.su",
+ "me.phh.superuser",
+ "com.kingroot.kinguser",
+ "com.kingouser.com",
+ "com.smedialink.oneclickroot",
+ "com.zhiqupk.root.global",
+ "com.alephzain.framaroot"
+ )
+
+ fun scan(context: Context): Map {
+ val reasons = mutableListOf()
+
+ if (Build.TAGS?.contains("test-keys") == true) {
+ reasons.add("test-keys build signature")
+ }
+
+ for (path in ROOT_PATHS) {
+ if (File(path).exists()) {
+ reasons.add("root binary present: $path")
+ break // one hit is enough signal — avoid noisy metadata
+ }
+ }
+
+ val pm = context.packageManager
+ for (pkg in ROOT_PACKAGES) {
+ try {
+ pm.getPackageInfo(pkg, 0)
+ reasons.add("root management app installed: $pkg")
+ } catch (_: PackageManager.NameNotFoundException) {
+ // Not installed — expected on clean devices.
+ }
+ }
+
+ try {
+ val buildProp = File("/system/build.prop")
+ if (buildProp.exists() && buildProp.canWrite()) {
+ reasons.add("system partition writable")
+ }
+ } catch (_: SecurityException) {
+ // Sandbox denies the check — inconclusive, not a finding.
+ }
+
+ return mapOf(
+ "rooted" to reasons.isNotEmpty(),
+ "reasons" to reasons
+ )
+ }
+}
diff --git a/app/android/app/src/main/res/drawable-hdpi/ic_launcher_background.png b/app/android/app/src/main/res/drawable-hdpi/ic_launcher_background.png
new file mode 100644
index 0000000..466bfdb
Binary files /dev/null and b/app/android/app/src/main/res/drawable-hdpi/ic_launcher_background.png differ
diff --git a/app/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/app/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..a22cdb5
Binary files /dev/null and b/app/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png differ
diff --git a/app/android/app/src/main/res/drawable-hdpi/ic_launcher_monochrome.png b/app/android/app/src/main/res/drawable-hdpi/ic_launcher_monochrome.png
new file mode 100644
index 0000000..a99f98e
Binary files /dev/null and b/app/android/app/src/main/res/drawable-hdpi/ic_launcher_monochrome.png differ
diff --git a/app/android/app/src/main/res/drawable-mdpi/ic_launcher_background.png b/app/android/app/src/main/res/drawable-mdpi/ic_launcher_background.png
new file mode 100644
index 0000000..105ab18
Binary files /dev/null and b/app/android/app/src/main/res/drawable-mdpi/ic_launcher_background.png differ
diff --git a/app/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/app/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..324564a
Binary files /dev/null and b/app/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png differ
diff --git a/app/android/app/src/main/res/drawable-mdpi/ic_launcher_monochrome.png b/app/android/app/src/main/res/drawable-mdpi/ic_launcher_monochrome.png
new file mode 100644
index 0000000..54db746
Binary files /dev/null and b/app/android/app/src/main/res/drawable-mdpi/ic_launcher_monochrome.png differ
diff --git a/app/android/app/src/main/res/drawable-night/launch_background.xml b/app/android/app/src/main/res/drawable-night/launch_background.xml
new file mode 100644
index 0000000..26c1583
--- /dev/null
+++ b/app/android/app/src/main/res/drawable-night/launch_background.xml
@@ -0,0 +1,15 @@
+
+
+
+ -
+
+
+
+
+
+ -
+
+
+
diff --git a/app/android/app/src/main/res/drawable-v21/launch_background.xml b/app/android/app/src/main/res/drawable-v21/launch_background.xml
new file mode 100644
index 0000000..d977420
--- /dev/null
+++ b/app/android/app/src/main/res/drawable-v21/launch_background.xml
@@ -0,0 +1,15 @@
+
+
+
+ -
+
+
+
+
+
+ -
+
+
+
diff --git a/app/android/app/src/main/res/drawable-xhdpi/ic_launcher_background.png b/app/android/app/src/main/res/drawable-xhdpi/ic_launcher_background.png
new file mode 100644
index 0000000..9372bcf
Binary files /dev/null and b/app/android/app/src/main/res/drawable-xhdpi/ic_launcher_background.png differ
diff --git a/app/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/app/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..5900648
Binary files /dev/null and b/app/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png differ
diff --git a/app/android/app/src/main/res/drawable-xhdpi/ic_launcher_monochrome.png b/app/android/app/src/main/res/drawable-xhdpi/ic_launcher_monochrome.png
new file mode 100644
index 0000000..f429fe1
Binary files /dev/null and b/app/android/app/src/main/res/drawable-xhdpi/ic_launcher_monochrome.png differ
diff --git a/app/android/app/src/main/res/drawable-xxhdpi/ic_launcher_background.png b/app/android/app/src/main/res/drawable-xxhdpi/ic_launcher_background.png
new file mode 100644
index 0000000..d0502c4
Binary files /dev/null and b/app/android/app/src/main/res/drawable-xxhdpi/ic_launcher_background.png differ
diff --git a/app/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/app/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..478f475
Binary files /dev/null and b/app/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png differ
diff --git a/app/android/app/src/main/res/drawable-xxhdpi/ic_launcher_monochrome.png b/app/android/app/src/main/res/drawable-xxhdpi/ic_launcher_monochrome.png
new file mode 100644
index 0000000..d0986b0
Binary files /dev/null and b/app/android/app/src/main/res/drawable-xxhdpi/ic_launcher_monochrome.png differ
diff --git a/app/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_background.png b/app/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_background.png
new file mode 100644
index 0000000..d842a06
Binary files /dev/null and b/app/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_background.png differ
diff --git a/app/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/app/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..6d41851
Binary files /dev/null and b/app/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/app/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_monochrome.png b/app/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_monochrome.png
new file mode 100644
index 0000000..78038bb
Binary files /dev/null and b/app/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_monochrome.png differ
diff --git a/app/android/app/src/main/res/drawable/launch_background.xml b/app/android/app/src/main/res/drawable/launch_background.xml
new file mode 100644
index 0000000..d977420
--- /dev/null
+++ b/app/android/app/src/main/res/drawable/launch_background.xml
@@ -0,0 +1,15 @@
+
+
+
+ -
+
+
+
+
+
+ -
+
+
+
diff --git a/app/android/app/src/main/res/mipmap-anydpi-v26/launcher_icon.xml b/app/android/app/src/main/res/mipmap-anydpi-v26/launcher_icon.xml
new file mode 100644
index 0000000..f606c4d
--- /dev/null
+++ b/app/android/app/src/main/res/mipmap-anydpi-v26/launcher_icon.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..db77bb4
Binary files /dev/null and b/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/app/android/app/src/main/res/mipmap-hdpi/launcher_icon.png b/app/android/app/src/main/res/mipmap-hdpi/launcher_icon.png
new file mode 100644
index 0000000..1729ae1
Binary files /dev/null and b/app/android/app/src/main/res/mipmap-hdpi/launcher_icon.png differ
diff --git a/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..17987b7
Binary files /dev/null and b/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/app/android/app/src/main/res/mipmap-mdpi/launcher_icon.png b/app/android/app/src/main/res/mipmap-mdpi/launcher_icon.png
new file mode 100644
index 0000000..8699594
Binary files /dev/null and b/app/android/app/src/main/res/mipmap-mdpi/launcher_icon.png differ
diff --git a/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..09d4391
Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/app/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png b/app/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png
new file mode 100644
index 0000000..a5ef40b
Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png differ
diff --git a/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..d5f1c8d
Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/app/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png b/app/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png
new file mode 100644
index 0000000..2ee4c44
Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png differ
diff --git a/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..4d6372e
Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/app/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png b/app/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png
new file mode 100644
index 0000000..9d2722f
Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png differ
diff --git a/app/android/app/src/main/res/values-night/styles.xml b/app/android/app/src/main/res/values-night/styles.xml
new file mode 100644
index 0000000..275bb86
--- /dev/null
+++ b/app/android/app/src/main/res/values-night/styles.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
diff --git a/app/android/app/src/main/res/values/colors.xml b/app/android/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..beab31f
--- /dev/null
+++ b/app/android/app/src/main/res/values/colors.xml
@@ -0,0 +1,4 @@
+
+
+ #000000
+
\ No newline at end of file
diff --git a/app/android/app/src/main/res/values/styles.xml b/app/android/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..a09629a
--- /dev/null
+++ b/app/android/app/src/main/res/values/styles.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
diff --git a/app/android/app/src/main/res/xml/network_security_config.xml b/app/android/app/src/main/res/xml/network_security_config.xml
new file mode 100644
index 0000000..a9f457e
--- /dev/null
+++ b/app/android/app/src/main/res/xml/network_security_config.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/android/app/src/profile/AndroidManifest.xml b/app/android/app/src/profile/AndroidManifest.xml
new file mode 100644
index 0000000..399f698
--- /dev/null
+++ b/app/android/app/src/profile/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/app/android/build.gradle.kts b/app/android/build.gradle.kts
new file mode 100644
index 0000000..abb193e
--- /dev/null
+++ b/app/android/build.gradle.kts
@@ -0,0 +1,96 @@
+apply(from = "jcenter_stub.gradle")
+
+buildscript {
+ repositories {
+ google()
+ mavenCentral()
+ }
+ dependencies {
+ classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.2.10")
+ }
+}
+
+allprojects {
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+val newBuildDir: Directory =
+ rootProject.layout.buildDirectory
+ .dir("../../build")
+ .get()
+rootProject.layout.buildDirectory.value(newBuildDir)
+
+subprojects {
+ val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
+ project.layout.buildDirectory.value(newSubprojectBuildDir)
+}
+
+subprojects {
+ project.evaluationDependsOn(":app")
+}
+
+subprojects {
+ if (name == "file_picker" || name == "screen_protector") {
+ plugins.apply("org.jetbrains.kotlin.android")
+ }
+}
+
+// Path A: Proper Gradle JVM Toolchain fix for JVM Target mismatches
+subprojects {
+ tasks.withType().configureEach {
+ compilerOptions {
+ jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
+ // Some third-party plugin modules (observed: sentry_flutter) ship
+ // without an explicit Kotlin languageVersion, which Gradle/AGP
+ // then defaults to something ancient (1.6) that this project's
+ // Kotlin 2.2.x compiler rejects outright ("Language version 1.6
+ // is no longer supported"). Same fix shape as the jvmTarget
+ // force above — apply a sane modern floor across every
+ // subproject rather than patching one plugin at a time.
+ languageVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9)
+ apiVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9)
+ }
+ }
+}
+
+subprojects {
+ val configureAndroid = {
+ val android = project.extensions.findByName("android")
+ if (android != null) {
+ // Force compileSdk 36 (Android 16/Baklava)
+ try {
+ val setCompileSdk = android.javaClass.getMethod("setCompileSdk", Int::class.javaPrimitiveType)
+ setCompileSdk.invoke(android, 36)
+ } catch (_: Exception) {
+ try {
+ val compileSdkVersion = android.javaClass.getMethod("compileSdkVersion", Int::class.javaPrimitiveType)
+ compileSdkVersion.invoke(android, 36)
+ } catch (_: Exception) { }
+ }
+
+ // Force Java 17 Compatibility across all modules (Path A)
+ try {
+ val compileOptions = android.javaClass.getMethod("getCompileOptions").invoke(android)
+ val setSourceCompatibility = compileOptions.javaClass.getMethod("setSourceCompatibility", JavaVersion::class.java)
+ val setTargetCompatibility = compileOptions.javaClass.getMethod("setTargetCompatibility", JavaVersion::class.java)
+ setSourceCompatibility.invoke(compileOptions, JavaVersion.VERSION_17)
+ setTargetCompatibility.invoke(compileOptions, JavaVersion.VERSION_17)
+ } catch (_: Exception) { }
+ }
+ }
+
+ if (project.state.executed) {
+ configureAndroid()
+ } else {
+ project.afterEvaluate {
+ configureAndroid()
+ }
+ }
+}
+
+tasks.register("clean") {
+ delete(rootProject.layout.buildDirectory)
+}
diff --git a/app/android/gradle.properties b/app/android/gradle.properties
new file mode 100644
index 0000000..6d60b4b
--- /dev/null
+++ b/app/android/gradle.properties
@@ -0,0 +1,5 @@
+org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=1G -XX:ReservedCodeCacheSize=256m
+android.useAndroidX=true
+# Reverted to false as AGP 9.0/Flutter plugin still requires legacy DSL
+android.newDsl=false
+android.builtInKotlin=false
diff --git a/app/android/gradle/gradle-daemon-jvm.properties b/app/android/gradle/gradle-daemon-jvm.properties
new file mode 100644
index 0000000..baa28d1
--- /dev/null
+++ b/app/android/gradle/gradle-daemon-jvm.properties
@@ -0,0 +1,13 @@
+#This file is generated by updateDaemonJvm
+toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
+toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
+toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
+toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
+toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7083b89563e7ce20943037b8cd2b8cc2/redirect
+toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/060bbb778a1f55ea705fdebd2ccfeab9/redirect
+toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
+toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
+toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/d09679dc60fe5aa05ef7d03efdefac20/redirect
+toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/ed4e3bf2f5e7c5d9aabc4cbd8acd555e/redirect
+toolchainVendor=JETBRAINS
+toolchainVersion=21
diff --git a/app/android/gradle/wrapper/gradle-wrapper.properties b/app/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..6d24045
--- /dev/null
+++ b/app/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,5 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
diff --git a/app/android/jcenter_stub.gradle b/app/android/jcenter_stub.gradle
new file mode 100644
index 0000000..ed55c15
--- /dev/null
+++ b/app/android/jcenter_stub.gradle
@@ -0,0 +1,18 @@
+// Dynamic Groovy metaclass injection to mock jcenter() repository method in older plugins
+try {
+ org.gradle.api.artifacts.dsl.RepositoryHandler.metaClass.jcenter = { ->
+ delegate.mavenCentral()
+ }
+} catch (Exception e) {
+ println "jcenter-stub: RepositoryHandler registration failed: $e"
+}
+
+try {
+ Class defaultRepoHandler = Class.forName("org.gradle.api.internal.artifacts.dsl.DefaultRepositoryHandler")
+ defaultRepoHandler.metaClass.jcenter = { ->
+ delegate.mavenCentral()
+ }
+ println "jcenter-stub: Successfully registered jcenter() stub on DefaultRepositoryHandler"
+} catch (Exception e) {
+ println "jcenter-stub: DefaultRepositoryHandler registration failed: $e"
+}
diff --git a/app/android/settings.gradle.kts b/app/android/settings.gradle.kts
new file mode 100644
index 0000000..c5d59cf
--- /dev/null
+++ b/app/android/settings.gradle.kts
@@ -0,0 +1,32 @@
+pluginManagement {
+ val flutterSdkPath =
+ run {
+ val properties = java.util.Properties()
+ file("local.properties").inputStream().use { properties.load(it) }
+ val flutterSdkPath = properties.getProperty("flutter.sdk")
+ require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
+ flutterSdkPath
+ }
+
+ includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
+
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+plugins {
+ id("dev.flutter.flutter-plugin-loader") version "1.0.0"
+ id("com.android.application") version "9.2.1" apply false
+ id("org.jetbrains.kotlin.android") version "2.2.20" apply false
+ // Declared but never applied here — app/build.gradle.kts applies it only
+ // when google-services.json is present. Applying it unconditionally makes
+ // the build fail outright on any checkout without that file, which is
+ // every checkout today.
+ id("com.google.gms.google-services") version "4.4.2" apply false
+ id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
+}
+
+include(":app")
diff --git a/app/assets/google_fonts/Inter-Bold.ttf b/app/assets/google_fonts/Inter-Bold.ttf
new file mode 100644
index 0000000..9c2f47d
Binary files /dev/null and b/app/assets/google_fonts/Inter-Bold.ttf differ
diff --git a/app/assets/google_fonts/Inter-Medium.ttf b/app/assets/google_fonts/Inter-Medium.ttf
new file mode 100644
index 0000000..4a57a1a
Binary files /dev/null and b/app/assets/google_fonts/Inter-Medium.ttf differ
diff --git a/app/assets/google_fonts/Inter-Regular.ttf b/app/assets/google_fonts/Inter-Regular.ttf
new file mode 100644
index 0000000..399a6e0
Binary files /dev/null and b/app/assets/google_fonts/Inter-Regular.ttf differ
diff --git a/app/assets/google_fonts/Inter-SemiBold.ttf b/app/assets/google_fonts/Inter-SemiBold.ttf
new file mode 100644
index 0000000..67fda28
Binary files /dev/null and b/app/assets/google_fonts/Inter-SemiBold.ttf differ
diff --git a/app/assets/google_fonts/Outfit-Bold.ttf b/app/assets/google_fonts/Outfit-Bold.ttf
new file mode 100644
index 0000000..0389ff2
Binary files /dev/null and b/app/assets/google_fonts/Outfit-Bold.ttf differ
diff --git a/app/assets/google_fonts/Outfit-Regular.ttf b/app/assets/google_fonts/Outfit-Regular.ttf
new file mode 100644
index 0000000..0b0a0d7
Binary files /dev/null and b/app/assets/google_fonts/Outfit-Regular.ttf differ
diff --git a/app/assets/google_fonts/Outfit-SemiBold.ttf b/app/assets/google_fonts/Outfit-SemiBold.ttf
new file mode 100644
index 0000000..52e25bc
Binary files /dev/null and b/app/assets/google_fonts/Outfit-SemiBold.ttf differ
diff --git a/app/assets/google_fonts/VT323-Regular.ttf b/app/assets/google_fonts/VT323-Regular.ttf
new file mode 100644
index 0000000..afa6909
Binary files /dev/null and b/app/assets/google_fonts/VT323-Regular.ttf differ
diff --git a/app/assets/icon/LuxandNox.png b/app/assets/icon/LuxandNox.png
new file mode 100644
index 0000000..1d6f0cf
Binary files /dev/null and b/app/assets/icon/LuxandNox.png differ
diff --git a/app/assets/icon/adaptive_foreground.png b/app/assets/icon/adaptive_foreground.png
new file mode 100644
index 0000000..4173f30
Binary files /dev/null and b/app/assets/icon/adaptive_foreground.png differ
diff --git a/app/assets/icon/adaptive_monochrome.png b/app/assets/icon/adaptive_monochrome.png
new file mode 100644
index 0000000..246dda4
Binary files /dev/null and b/app/assets/icon/adaptive_monochrome.png differ
diff --git a/app/assets/icon/app_icon.png b/app/assets/icon/app_icon.png
new file mode 100644
index 0000000..dacc90f
Binary files /dev/null and b/app/assets/icon/app_icon.png differ
diff --git a/app/assets/icon/founder_avatar.jpeg b/app/assets/icon/founder_avatar.jpeg
new file mode 100644
index 0000000..01a6a7c
Binary files /dev/null and b/app/assets/icon/founder_avatar.jpeg differ
diff --git a/app/assets/icon/transparent_foreground.png b/app/assets/icon/transparent_foreground.png
new file mode 100644
index 0000000..0320df1
Binary files /dev/null and b/app/assets/icon/transparent_foreground.png differ
diff --git a/app/assets/images/avatar_academic.png b/app/assets/images/avatar_academic.png
new file mode 100644
index 0000000..5a84933
Binary files /dev/null and b/app/assets/images/avatar_academic.png differ
diff --git a/app/assets/images/avatar_archivist.png b/app/assets/images/avatar_archivist.png
new file mode 100644
index 0000000..b9aa74c
Binary files /dev/null and b/app/assets/images/avatar_archivist.png differ
diff --git a/app/assets/images/avatar_builder.png b/app/assets/images/avatar_builder.png
new file mode 100644
index 0000000..90bedb7
Binary files /dev/null and b/app/assets/images/avatar_builder.png differ
diff --git a/app/assets/images/avatar_chaos.png b/app/assets/images/avatar_chaos.png
new file mode 100644
index 0000000..be35058
Binary files /dev/null and b/app/assets/images/avatar_chaos.png differ
diff --git a/app/assets/images/avatar_creator.png b/app/assets/images/avatar_creator.png
new file mode 100644
index 0000000..405cf01
Binary files /dev/null and b/app/assets/images/avatar_creator.png differ
diff --git a/app/assets/images/avatar_researcher.png b/app/assets/images/avatar_researcher.png
new file mode 100644
index 0000000..5c184b0
Binary files /dev/null and b/app/assets/images/avatar_researcher.png differ
diff --git a/app/assets/mascot/README.md b/app/assets/mascot/README.md
new file mode 100644
index 0000000..3e9277c
--- /dev/null
+++ b/app/assets/mascot/README.md
@@ -0,0 +1,22 @@
+# Mascot assets
+
+Drop the authored Rive files here with these exact names — `MascotView` (see
+`lib/core/mascot/mascot_view.dart`) looks them up by convention:
+
+- `lux.riv`
+- `nox.riv`
+- `duo.riv`
+
+Each file must expose a State Machine named `SM` with:
+
+- a `Number` input named `stateIndex` (see `MascotMood` order in
+ `lib/core/mascot/mascot_state.dart` for the index mapping)
+- a `Boolean` input named `reducedMotion` (when true, the artboard should hold on its
+ Idle frame regardless of `stateIndex`)
+
+Until a given file exists, `MascotView` falls back automatically (see its `placeHolder`) —
+solo Lux/Nox render nothing, and the duo placeholder renders the static
+`assets/icon/LuxandNox.png` mark. No code changes are needed when the real files are added,
+just drop them in.
+
+See `MASCOT_GUIDE.md` (repo root) for the full animation spec.
diff --git a/app/assets/screenshots/1.png b/app/assets/screenshots/1.png
new file mode 100644
index 0000000..c3ca471
Binary files /dev/null and b/app/assets/screenshots/1.png differ
diff --git a/app/assets/screenshots/10.png b/app/assets/screenshots/10.png
new file mode 100644
index 0000000..0bae54d
Binary files /dev/null and b/app/assets/screenshots/10.png differ
diff --git a/app/assets/screenshots/11.png b/app/assets/screenshots/11.png
new file mode 100644
index 0000000..9fe8422
Binary files /dev/null and b/app/assets/screenshots/11.png differ
diff --git a/app/assets/screenshots/13.png b/app/assets/screenshots/13.png
new file mode 100644
index 0000000..6b3d19f
Binary files /dev/null and b/app/assets/screenshots/13.png differ
diff --git a/app/assets/screenshots/15.png b/app/assets/screenshots/15.png
new file mode 100644
index 0000000..bde0abe
Binary files /dev/null and b/app/assets/screenshots/15.png differ
diff --git a/app/assets/screenshots/16.png b/app/assets/screenshots/16.png
new file mode 100644
index 0000000..5a72101
Binary files /dev/null and b/app/assets/screenshots/16.png differ
diff --git a/app/assets/screenshots/17.png b/app/assets/screenshots/17.png
new file mode 100644
index 0000000..51556c1
Binary files /dev/null and b/app/assets/screenshots/17.png differ
diff --git a/app/assets/screenshots/18.png b/app/assets/screenshots/18.png
new file mode 100644
index 0000000..0683135
Binary files /dev/null and b/app/assets/screenshots/18.png differ
diff --git a/app/assets/screenshots/19.png b/app/assets/screenshots/19.png
new file mode 100644
index 0000000..6fd94a2
Binary files /dev/null and b/app/assets/screenshots/19.png differ
diff --git a/app/assets/screenshots/2.png b/app/assets/screenshots/2.png
new file mode 100644
index 0000000..50410df
Binary files /dev/null and b/app/assets/screenshots/2.png differ
diff --git a/app/assets/screenshots/4.png b/app/assets/screenshots/4.png
new file mode 100644
index 0000000..9eee01e
Binary files /dev/null and b/app/assets/screenshots/4.png differ
diff --git a/app/assets/screenshots/5.png b/app/assets/screenshots/5.png
new file mode 100644
index 0000000..bc7b255
Binary files /dev/null and b/app/assets/screenshots/5.png differ
diff --git a/app/assets/screenshots/6.png b/app/assets/screenshots/6.png
new file mode 100644
index 0000000..b040b14
Binary files /dev/null and b/app/assets/screenshots/6.png differ
diff --git a/app/assets/screenshots/7.png b/app/assets/screenshots/7.png
new file mode 100644
index 0000000..d10d54e
Binary files /dev/null and b/app/assets/screenshots/7.png differ
diff --git a/app/assets/screenshots/8.png b/app/assets/screenshots/8.png
new file mode 100644
index 0000000..86df7a4
Binary files /dev/null and b/app/assets/screenshots/8.png differ
diff --git a/app/assets/screenshots/9.png b/app/assets/screenshots/9.png
new file mode 100644
index 0000000..f91a4a0
Binary files /dev/null and b/app/assets/screenshots/9.png differ
diff --git a/app/ios/.gitignore b/app/ios/.gitignore
new file mode 100644
index 0000000..7a7f987
--- /dev/null
+++ b/app/ios/.gitignore
@@ -0,0 +1,34 @@
+**/dgph
+*.mode1v3
+*.mode2v3
+*.moved-aside
+*.pbxuser
+*.perspectivev3
+**/*sync/
+.sconsign.dblite
+.tags*
+**/.vagrant/
+**/DerivedData/
+Icon?
+**/Pods/
+**/.symlinks/
+profile
+xcuserdata
+**/.generated/
+Flutter/App.framework
+Flutter/Flutter.framework
+Flutter/Flutter.podspec
+Flutter/Generated.xcconfig
+Flutter/ephemeral/
+Flutter/app.flx
+Flutter/app.zip
+Flutter/flutter_assets/
+Flutter/flutter_export_environment.sh
+ServiceDefinitions.json
+Runner/GeneratedPluginRegistrant.*
+
+# Exceptions to above rules.
+!default.mode1v3
+!default.mode2v3
+!default.pbxuser
+!default.perspectivev3
diff --git a/app/ios/Flutter/AppFrameworkInfo.plist b/app/ios/Flutter/AppFrameworkInfo.plist
new file mode 100644
index 0000000..391a902
--- /dev/null
+++ b/app/ios/Flutter/AppFrameworkInfo.plist
@@ -0,0 +1,24 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleExecutable
+ App
+ CFBundleIdentifier
+ io.flutter.flutter.app
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ App
+ CFBundlePackageType
+ FMWK
+ CFBundleShortVersionString
+ 1.0
+ CFBundleSignature
+ ????
+ CFBundleVersion
+ 1.0
+
+
diff --git a/app/ios/Flutter/Debug.xcconfig b/app/ios/Flutter/Debug.xcconfig
new file mode 100644
index 0000000..592ceee
--- /dev/null
+++ b/app/ios/Flutter/Debug.xcconfig
@@ -0,0 +1 @@
+#include "Generated.xcconfig"
diff --git a/app/ios/Flutter/Release.xcconfig b/app/ios/Flutter/Release.xcconfig
new file mode 100644
index 0000000..592ceee
--- /dev/null
+++ b/app/ios/Flutter/Release.xcconfig
@@ -0,0 +1 @@
+#include "Generated.xcconfig"
diff --git a/app/ios/Runner.xcodeproj/project.pbxproj b/app/ios/Runner.xcodeproj/project.pbxproj
new file mode 100644
index 0000000..03a374d
--- /dev/null
+++ b/app/ios/Runner.xcodeproj/project.pbxproj
@@ -0,0 +1,644 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 54;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
+ 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
+ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
+ 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
+ 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
+ 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
+ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
+ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
+ 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXContainerItemProxy section */
+ 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 97C146E61CF9000F007C117D /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 97C146ED1CF9000F007C117D;
+ remoteInfo = Runner;
+ };
+/* End PBXContainerItemProxy section */
+
+/* Begin PBXCopyFilesBuildPhase section */
+ 9705A1C41CF9048500538489 /* Embed Frameworks */ = {
+ isa = PBXCopyFilesBuildPhase;
+ buildActionMask = 2147483647;
+ dstPath = "";
+ dstSubfolderSpec = 10;
+ files = (
+ );
+ name = "Embed Frameworks";
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXCopyFilesBuildPhase section */
+
+/* Begin PBXFileReference section */
+ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; };
+ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; };
+ 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; };
+ 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+ 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; };
+ 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; };
+ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
+ 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; };
+ 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; };
+ 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; };
+ 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; };
+ 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; };
+ 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; };
+ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
+ 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; };
+ 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ 97C146EB1CF9000F007C117D /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ 331C8082294A63A400263BE5 /* RunnerTests */ = {
+ isa = PBXGroup;
+ children = (
+ 331C807B294A618700263BE5 /* RunnerTests.swift */,
+ );
+ path = RunnerTests;
+ sourceTree = "";
+ };
+ 9740EEB11CF90186004384FC /* Flutter */ = {
+ isa = PBXGroup;
+ children = (
+ 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
+ 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
+ 9740EEB21CF90195004384FC /* Debug.xcconfig */,
+ 7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
+ 9740EEB31CF90195004384FC /* Generated.xcconfig */,
+ );
+ name = Flutter;
+ sourceTree = "";
+ };
+ 97C146E51CF9000F007C117D = {
+ isa = PBXGroup;
+ children = (
+ 9740EEB11CF90186004384FC /* Flutter */,
+ 97C146F01CF9000F007C117D /* Runner */,
+ 97C146EF1CF9000F007C117D /* Products */,
+ 331C8082294A63A400263BE5 /* RunnerTests */,
+ );
+ sourceTree = "";
+ };
+ 97C146EF1CF9000F007C117D /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ 97C146EE1CF9000F007C117D /* Runner.app */,
+ 331C8081294A63A400263BE5 /* RunnerTests.xctest */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+ 97C146F01CF9000F007C117D /* Runner */ = {
+ isa = PBXGroup;
+ children = (
+ 97C146FA1CF9000F007C117D /* Main.storyboard */,
+ 97C146FD1CF9000F007C117D /* Assets.xcassets */,
+ 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
+ 97C147021CF9000F007C117D /* Info.plist */,
+ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
+ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
+ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
+ 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
+ 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
+ );
+ path = Runner;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ 331C8080294A63A400263BE5 /* RunnerTests */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
+ buildPhases = (
+ 331C807D294A63A400263BE5 /* Sources */,
+ 331C807F294A63A400263BE5 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ 331C8086294A63A400263BE5 /* PBXTargetDependency */,
+ );
+ name = RunnerTests;
+ productName = RunnerTests;
+ productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
+ productType = "com.apple.product-type.bundle.unit-test";
+ };
+ 97C146ED1CF9000F007C117D /* Runner */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
+ buildPhases = (
+ 9740EEB61CF901F6004384FC /* Run Script */,
+ 97C146EA1CF9000F007C117D /* Sources */,
+ 97C146EB1CF9000F007C117D /* Frameworks */,
+ 97C146EC1CF9000F007C117D /* Resources */,
+ 9705A1C41CF9048500538489 /* Embed Frameworks */,
+ 3B06AD1E1E4923F5004D2608 /* Thin Binary */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = Runner;
+ packageProductDependencies = (
+ 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
+ );
+ productName = Runner;
+ productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ 97C146E61CF9000F007C117D /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = YES;
+ LastUpgradeCheck = 1510;
+ ORGANIZATIONNAME = "";
+ TargetAttributes = {
+ 331C8080294A63A400263BE5 = {
+ CreatedOnToolsVersion = 14.0;
+ TestTargetID = 97C146ED1CF9000F007C117D;
+ };
+ 97C146ED1CF9000F007C117D = {
+ CreatedOnToolsVersion = 7.3.1;
+ LastSwiftMigration = 1100;
+ };
+ };
+ };
+ buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
+ compatibilityVersion = "Xcode 9.3";
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = 97C146E51CF9000F007C117D;
+ packageReferences = (
+ 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
+ );
+ productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ 97C146ED1CF9000F007C117D /* Runner */,
+ 331C8080294A63A400263BE5 /* RunnerTests */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ 331C807F294A63A400263BE5 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 97C146EC1CF9000F007C117D /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
+ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
+ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
+ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXShellScriptBuildPhase section */
+ 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
+ isa = PBXShellScriptBuildPhase;
+ alwaysOutOfDate = 1;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputPaths = (
+ "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
+ );
+ name = "Thin Binary";
+ outputPaths = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
+ };
+ 9740EEB61CF901F6004384FC /* Run Script */ = {
+ isa = PBXShellScriptBuildPhase;
+ alwaysOutOfDate = 1;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputPaths = (
+ );
+ name = "Run Script";
+ outputPaths = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
+ };
+/* End PBXShellScriptBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ 331C807D294A63A400263BE5 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 97C146EA1CF9000F007C117D /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
+ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
+ 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin PBXTargetDependency section */
+ 331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 97C146ED1CF9000F007C117D /* Runner */;
+ targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
+ };
+/* End PBXTargetDependency section */
+
+/* Begin PBXVariantGroup section */
+ 97C146FA1CF9000F007C117D /* Main.storyboard */ = {
+ isa = PBXVariantGroup;
+ children = (
+ 97C146FB1CF9000F007C117D /* Base */,
+ );
+ name = Main.storyboard;
+ sourceTree = "";
+ };
+ 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
+ isa = PBXVariantGroup;
+ children = (
+ 97C147001CF9000F007C117D /* Base */,
+ );
+ name = LaunchScreen.storyboard;
+ sourceTree = "";
+ };
+/* End PBXVariantGroup section */
+
+/* Begin XCBuildConfiguration section */
+ 249021D3217E4FDB00AE95B9 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ SDKROOT = iphoneos;
+ SUPPORTED_PLATFORMS = iphoneos;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Profile;
+ };
+ 249021D4217E4FDB00AE95B9 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CLANG_ENABLE_MODULES = YES;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ ENABLE_BITCODE = NO;
+ INFOPLIST_FILE = Runner/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.example.noSus;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
+ SWIFT_VERSION = 5.0;
+ VERSIONING_SYSTEM = "apple-generic";
+ };
+ name = Profile;
+ };
+ 331C8088294A63A400263BE5 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.example.noSus.RunnerTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_VERSION = 5.0;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
+ };
+ name = Debug;
+ };
+ 331C8089294A63A400263BE5 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.example.noSus.RunnerTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_VERSION = 5.0;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
+ };
+ name = Release;
+ };
+ 331C808A294A63A400263BE5 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.example.noSus.RunnerTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_VERSION = 5.0;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
+ };
+ name = Profile;
+ };
+ 97C147031CF9000F007C117D /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ MTL_ENABLE_DEBUG_INFO = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = iphoneos;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ 97C147041CF9000F007C117D /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ SDKROOT = iphoneos;
+ SUPPORTED_PLATFORMS = iphoneos;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ SWIFT_OPTIMIZATION_LEVEL = "-O";
+ TARGETED_DEVICE_FAMILY = "1,2";
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
+ 97C147061CF9000F007C117D /* Debug */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CLANG_ENABLE_MODULES = YES;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ ENABLE_BITCODE = NO;
+ INFOPLIST_FILE = Runner/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.example.noSus;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_VERSION = 5.0;
+ VERSIONING_SYSTEM = "apple-generic";
+ };
+ name = Debug;
+ };
+ 97C147071CF9000F007C117D /* Release */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CLANG_ENABLE_MODULES = YES;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ ENABLE_BITCODE = NO;
+ INFOPLIST_FILE = Runner/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.example.noSus;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
+ SWIFT_VERSION = 5.0;
+ VERSIONING_SYSTEM = "apple-generic";
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 331C8088294A63A400263BE5 /* Debug */,
+ 331C8089294A63A400263BE5 /* Release */,
+ 331C808A294A63A400263BE5 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 97C147031CF9000F007C117D /* Debug */,
+ 97C147041CF9000F007C117D /* Release */,
+ 249021D3217E4FDB00AE95B9 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 97C147061CF9000F007C117D /* Debug */,
+ 97C147071CF9000F007C117D /* Release */,
+ 249021D4217E4FDB00AE95B9 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+
+/* Begin XCLocalSwiftPackageReference section */
+ 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
+ isa = XCLocalSwiftPackageReference;
+ relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
+ };
+/* End XCLocalSwiftPackageReference section */
+
+/* Begin XCSwiftPackageProductDependency section */
+ 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
+ isa = XCSwiftPackageProductDependency;
+ productName = FlutterGeneratedPluginSwiftPackage;
+ };
+/* End XCSwiftPackageProductDependency section */
+ };
+ rootObject = 97C146E61CF9000F007C117D /* Project object */;
+}
diff --git a/app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
new file mode 100644
index 0000000..c3fedb2
--- /dev/null
+++ b/app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
@@ -0,0 +1,119 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/ios/Runner/AppDelegate.swift b/app/ios/Runner/AppDelegate.swift
new file mode 100644
index 0000000..c30b367
--- /dev/null
+++ b/app/ios/Runner/AppDelegate.swift
@@ -0,0 +1,16 @@
+import Flutter
+import UIKit
+
+@main
+@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
+ override func application(
+ _ application: UIApplication,
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
+ ) -> Bool {
+ return super.application(application, didFinishLaunchingWithOptions: launchOptions)
+ }
+
+ func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
+ GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
+ }
+}
diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..d36b1fa
--- /dev/null
+++ b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,122 @@
+{
+ "images" : [
+ {
+ "size" : "20x20",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-20x20@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "20x20",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-20x20@3x.png",
+ "scale" : "3x"
+ },
+ {
+ "size" : "29x29",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-29x29@1x.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "29x29",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-29x29@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "29x29",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-29x29@3x.png",
+ "scale" : "3x"
+ },
+ {
+ "size" : "40x40",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-40x40@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "40x40",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-40x40@3x.png",
+ "scale" : "3x"
+ },
+ {
+ "size" : "60x60",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-60x60@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "60x60",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-60x60@3x.png",
+ "scale" : "3x"
+ },
+ {
+ "size" : "20x20",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-20x20@1x.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "20x20",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-20x20@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "29x29",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-29x29@1x.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "29x29",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-29x29@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "40x40",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-40x40@1x.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "40x40",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-40x40@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "76x76",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-76x76@1x.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "76x76",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-76x76@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "83.5x83.5",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-83.5x83.5@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "1024x1024",
+ "idiom" : "ios-marketing",
+ "filename" : "Icon-App-1024x1024@1x.png",
+ "scale" : "1x"
+ }
+ ],
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png
new file mode 100644
index 0000000..57ff2ed
Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ
diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png
new file mode 100644
index 0000000..710b6e7
Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ
diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png
new file mode 100644
index 0000000..a06f10d
Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ
diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png
new file mode 100644
index 0000000..ade47ee
Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ
diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png
new file mode 100644
index 0000000..fad73ad
Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ
diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png
new file mode 100644
index 0000000..58d2c5f
Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ
diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png
new file mode 100644
index 0000000..ff362f0
Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ
diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png
new file mode 100644
index 0000000..a06f10d
Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ
diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png
new file mode 100644
index 0000000..a4139d6
Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ
diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png
new file mode 100644
index 0000000..278973f
Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ
diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png
new file mode 100644
index 0000000..04f472d
Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ
diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png
new file mode 100644
index 0000000..f556f7f
Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ
diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png
new file mode 100644
index 0000000..eef6af3
Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ
diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png
new file mode 100644
index 0000000..0bf566a
Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ
diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png
new file mode 100644
index 0000000..278973f
Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ
diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png
new file mode 100644
index 0000000..e4fef2c
Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ
diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png
new file mode 100644
index 0000000..d6c2ec3
Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ
diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png
new file mode 100644
index 0000000..2ee4c44
Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ
diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png
new file mode 100644
index 0000000..21c6a6f
Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ
diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png
new file mode 100644
index 0000000..d6258d1
Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ
diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png
new file mode 100644
index 0000000..2e08867
Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ
diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
new file mode 100644
index 0000000..0bedcf2
--- /dev/null
+++ b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
@@ -0,0 +1,23 @@
+{
+ "images" : [
+ {
+ "idiom" : "universal",
+ "filename" : "LaunchImage.png",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "filename" : "LaunchImage@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "filename" : "LaunchImage@3x.png",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png
new file mode 100644
index 0000000..9da19ea
Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ
diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png
new file mode 100644
index 0000000..9da19ea
Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ
diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png
new file mode 100644
index 0000000..9da19ea
Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ
diff --git a/app/ios/Runner/Base.lproj/LaunchScreen.storyboard b/app/ios/Runner/Base.lproj/LaunchScreen.storyboard
new file mode 100644
index 0000000..f2e259c
--- /dev/null
+++ b/app/ios/Runner/Base.lproj/LaunchScreen.storyboard
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/ios/Runner/Base.lproj/Main.storyboard b/app/ios/Runner/Base.lproj/Main.storyboard
new file mode 100644
index 0000000..f3c2851
--- /dev/null
+++ b/app/ios/Runner/Base.lproj/Main.storyboard
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/ios/Runner/Info.plist b/app/ios/Runner/Info.plist
new file mode 100644
index 0000000..60b226e
--- /dev/null
+++ b/app/ios/Runner/Info.plist
@@ -0,0 +1,95 @@
+
+
+
+
+ CADisableMinimumFrameDurationOnPhone
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ No Sus
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ no_sus
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ $(FLUTTER_BUILD_NAME)
+ CFBundleSignature
+ ????
+ CFBundleVersion
+ $(FLUTTER_BUILD_NUMBER)
+ LSRequiresIPhoneOS
+
+ UIApplicationSceneManifest
+
+ UIApplicationSupportsMultipleScenes
+
+ UISceneConfigurations
+
+ UIWindowSceneSessionRoleApplication
+
+
+ UISceneClassName
+ UIWindowScene
+ UISceneConfigurationName
+ flutter
+ UISceneDelegateClassName
+ $(PRODUCT_MODULE_NAME).SceneDelegate
+ UISceneStoryboardFile
+ Main
+
+
+
+
+ UIApplicationSupportsIndirectInputEvents
+
+ UILaunchStoryboardName
+ LaunchScreen
+ UIMainStoryboardFile
+ Main
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ NSPhotoLibraryUsageDescription
+ This app requires access to your photo library to pick and share secure study notes and documents.
+ NSPhotoLibraryAddUsageDescription
+ This app requires permission to save secure documents to your photo library.
+ FlutterDeepLinkingEnabled
+
+ CFBundleURLTypes
+
+
+ CFBundleURLName
+ io.supabase.nosus
+ CFBundleURLSchemes
+
+ io.supabase.nosus
+
+
+
+ CFBundleURLName
+ foo.nosus.app
+ CFBundleURLSchemes
+
+ foo.nosus.app
+
+
+
+
+
diff --git a/app/ios/Runner/Runner-Bridging-Header.h b/app/ios/Runner/Runner-Bridging-Header.h
new file mode 100644
index 0000000..308a2a5
--- /dev/null
+++ b/app/ios/Runner/Runner-Bridging-Header.h
@@ -0,0 +1 @@
+#import "GeneratedPluginRegistrant.h"
diff --git a/app/ios/Runner/SceneDelegate.swift b/app/ios/Runner/SceneDelegate.swift
new file mode 100644
index 0000000..b9ce8ea
--- /dev/null
+++ b/app/ios/Runner/SceneDelegate.swift
@@ -0,0 +1,6 @@
+import Flutter
+import UIKit
+
+class SceneDelegate: FlutterSceneDelegate {
+
+}
diff --git a/app/ios/RunnerTests/RunnerTests.swift b/app/ios/RunnerTests/RunnerTests.swift
new file mode 100644
index 0000000..86a7c3b
--- /dev/null
+++ b/app/ios/RunnerTests/RunnerTests.swift
@@ -0,0 +1,12 @@
+import Flutter
+import UIKit
+import XCTest
+
+class RunnerTests: XCTestCase {
+
+ func testExample() {
+ // If you add code to the Runner application, consider adding tests here.
+ // See https://developer.apple.com/documentation/xctest for more information about using XCTest.
+ }
+
+}
diff --git a/app/lib/components/async_state_view.dart b/app/lib/components/async_state_view.dart
new file mode 100644
index 0000000..e34ac0e
--- /dev/null
+++ b/app/lib/components/async_state_view.dart
@@ -0,0 +1,103 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import '../theme.dart';
+
+/// Generic loading/error/data wrapper for an [AsyncValue], generalizing the
+/// `.when(loading:, error:, data:)` + skeleton + error-with-retry pattern
+/// originally hand-written in `groups_screen.dart` (`_SkeletonList`,
+/// `_ErrorState`). Does not own a `RefreshIndicator` — callers keep their
+/// own since the invalidate/await-future call differs per provider.
+class AsyncStateView extends StatelessWidget {
+ final AsyncValue value;
+ final Widget Function(BuildContext context, T data) data;
+ final WidgetBuilder? loading;
+ final String errorMessage;
+ final VoidCallback onRetry;
+ final bool Function(T data)? isEmpty;
+ final WidgetBuilder? empty;
+
+ const AsyncStateView({
+ super.key,
+ required this.value,
+ required this.data,
+ this.loading,
+ this.errorMessage = 'Something went wrong.',
+ required this.onRetry,
+ this.isEmpty,
+ this.empty,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return value.when(
+ loading: () =>
+ loading?.call(context) ??
+ const Center(child: CircularProgressIndicator()),
+ error: (error, stack) =>
+ _AsyncErrorState(message: errorMessage, onRetry: onRetry),
+ data: (value) {
+ if (isEmpty != null && empty != null && isEmpty!(value)) {
+ return empty!(context);
+ }
+ return data(context, value);
+ },
+ );
+ }
+}
+
+class _AsyncErrorState extends StatelessWidget {
+ final String message;
+ final VoidCallback onRetry;
+
+ const _AsyncErrorState({required this.message, required this.onRetry});
+
+ @override
+ Widget build(BuildContext context) {
+ final subtle = Theme.of(
+ context,
+ ).colorScheme.onSurface.withValues(alpha: 0.4);
+ return LayoutBuilder(
+ builder: (context, constraints) => SingleChildScrollView(
+ physics: AlwaysScrollableScrollPhysics(
+ parent: NoSusTheme.getScrollPhysics(context),
+ ),
+ child: ConstrainedBox(
+ constraints: BoxConstraints(minHeight: constraints.maxHeight),
+ child: Center(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(Icons.wifi_off_outlined, size: 36, color: subtle),
+ const SizedBox(height: 12),
+ Text(message, style: TextStyle(color: subtle, fontSize: 14)),
+ const SizedBox(height: 16),
+ Semantics(
+ button: true,
+ label: 'Retry',
+ child: InkWell(
+ onTap: onRetry,
+ borderRadius: BorderRadius.circular(8),
+ child: Padding(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 16,
+ vertical: 12,
+ ),
+ child: Text(
+ 'RETRY',
+ style: TextStyle(
+ fontSize: 11,
+ color: subtle,
+ letterSpacing: 2.0,
+ ),
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/app/lib/components/coach_mark.dart b/app/lib/components/coach_mark.dart
new file mode 100644
index 0000000..42cd912
--- /dev/null
+++ b/app/lib/components/coach_mark.dart
@@ -0,0 +1,525 @@
+import 'dart:async';
+import 'dart:math' as math;
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/scheduler.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import '../features/onboarding/presentation/providers/tour_providers.dart';
+import '../theme.dart';
+
+/// One anchored tip: a spotlight cut out around [targetKey]'s widget, plus a
+/// bubble explaining what it is for.
+class CoachMarkStep {
+ /// Must be one of [TourSteps] — it is the persistence key.
+ final String id;
+
+ /// The widget to spotlight. If it is not mounted and laid out when the step
+ /// comes up, the bubble is centred instead of anchored, rather than skipped:
+ /// the explanation is the point, the highlight is decoration.
+ final GlobalKey targetKey;
+ final String title;
+ final String body;
+
+ /// Extra padding around the cut-out, so the highlight does not sit flush
+ /// against the control's edge.
+ final double inflate;
+
+ const CoachMarkStep({
+ required this.id,
+ required this.targetKey,
+ required this.title,
+ required this.body,
+ this.inflate = 8,
+ });
+}
+
+/// Geometry for keeping a tip inside the visible safe rectangle.
+///
+/// Flutter web typically reports `padding.bottom == 0` even when Chrome's
+/// toolbar and the Android gesture bar overlay the canvas. Placement uses
+/// view padding plus a web chrome floor so the action row is never clipped.
+@visibleForTesting
+abstract final class CoachMarkPlacement {
+ static const double webBottomChrome = 32;
+
+ static double topInset(MediaQueryData media) =>
+ math.max(media.padding.top, media.viewPadding.top);
+
+ static double bottomInset(MediaQueryData media, {required bool isWeb}) {
+ return math.max(
+ media.viewInsets.bottom,
+ math.max(
+ media.padding.bottom,
+ math.max(media.viewPadding.bottom, isWeb ? webBottomChrome : 0),
+ ),
+ );
+ }
+
+ static Rect safeViewport(
+ Size screen,
+ MediaQueryData media, {
+ required bool isWeb,
+ }) {
+ final top = topInset(media) + NoSusTheme.s12;
+ final bottom =
+ screen.height - bottomInset(media, isWeb: isWeb) - NoSusTheme.s12;
+ return Rect.fromLTRB(
+ NoSusTheme.s24,
+ top,
+ math.max(NoSusTheme.s24, screen.width - NoSusTheme.s24),
+ math.max(top, bottom),
+ );
+ }
+
+ static bool targetVisible(Rect? target, Rect viewport) {
+ if (target == null) return false;
+ return target.overlaps(viewport);
+ }
+
+ static bool placeBelow(Rect target, Rect viewport) {
+ final spaceBelow = viewport.bottom - (target.bottom + NoSusTheme.s16);
+ final spaceAbove = target.top - NoSusTheme.s16 - viewport.top;
+ const minimumReadableHeight = 160.0;
+ return spaceBelow >= minimumReadableHeight || spaceBelow >= spaceAbove;
+ }
+}
+
+/// Presents a sequence of [CoachMarkStep]s, one at a time, over the current
+/// route.
+///
+/// Call from a post-frame callback — it measures the target's render box, which
+/// does not exist until after layout.
+///
+/// Accessibility: the bubble carries the full explanation as text and is
+/// announced as a live region, so the tip is complete without seeing the
+/// highlight. The scrim animation is dropped when the platform asks for reduced
+/// motion.
+abstract final class CoachMarks {
+ static bool _isShowing = false;
+
+ static Future showSequence(
+ BuildContext context,
+ WidgetRef ref,
+ List steps,
+ ) async {
+ // One sequence at a time. Two tabs racing to present on the same frame
+ // would otherwise stack overlays on top of each other.
+ if (_isShowing) return;
+ if (!ref.read(tipsEnabledProvider)) return;
+
+ final progress = ref.read(tourProgressProvider.notifier);
+ final pending = steps
+ .where((s) => !progress.hasSeen(s.id))
+ .toList(growable: false);
+ if (pending.isEmpty) return;
+
+ final overlay = Overlay.maybeOf(context);
+ if (overlay == null) return;
+
+ _isShowing = true;
+ try {
+ for (var i = 0; i < pending.length; i++) {
+ if (!context.mounted) break;
+ final outcome = await _showOne(
+ overlay,
+ context,
+ pending[i],
+ isLast: i == pending.length - 1,
+ index: i,
+ total: pending.length,
+ );
+ await progress.markSeen([pending[i].id]);
+ if (outcome == _CoachMarkOutcome.skipAll) {
+ // "Skip" means skip the rest of this sequence, not suppress tips
+ // forever — that is what the Settings toggle is for.
+ await progress.markSeen(pending.skip(i + 1).map((s) => s.id));
+ break;
+ }
+ }
+ } finally {
+ _isShowing = false;
+ }
+ }
+
+ static Future<_CoachMarkOutcome> _showOne(
+ OverlayState overlay,
+ BuildContext context,
+ CoachMarkStep step, {
+ required bool isLast,
+ required int index,
+ required int total,
+ }) {
+ final completer = Completer<_CoachMarkOutcome>();
+ late OverlayEntry entry;
+
+ void close(_CoachMarkOutcome outcome) {
+ if (completer.isCompleted) return;
+ entry.remove();
+ completer.complete(outcome);
+ }
+
+ entry = OverlayEntry(
+ builder: (overlayContext) => _CoachMarkLayer(
+ step: step,
+ isLast: isLast,
+ index: index,
+ total: total,
+ onNext: () => close(_CoachMarkOutcome.next),
+ onSkip: () => close(_CoachMarkOutcome.skipAll),
+ ),
+ );
+
+ overlay.insert(entry);
+ return completer.future;
+ }
+}
+
+enum _CoachMarkOutcome { next, skipAll }
+
+class _CoachMarkLayer extends StatefulWidget {
+ final CoachMarkStep step;
+ final bool isLast;
+ final int index;
+ final int total;
+ final VoidCallback onNext;
+ final VoidCallback onSkip;
+
+ const _CoachMarkLayer({
+ required this.step,
+ required this.isLast,
+ required this.index,
+ required this.total,
+ required this.onNext,
+ required this.onSkip,
+ });
+
+ @override
+ State<_CoachMarkLayer> createState() => _CoachMarkLayerState();
+}
+
+class _CoachMarkLayerState extends State<_CoachMarkLayer>
+ with WidgetsBindingObserver {
+ Rect? _targetRect;
+
+ @override
+ void initState() {
+ super.initState();
+ WidgetsBinding.instance.addObserver(this);
+ SchedulerBinding.instance.addPostFrameCallback((_) => _measure());
+ }
+
+ @override
+ void didChangeMetrics() {
+ SchedulerBinding.instance.addPostFrameCallback((_) => _measure());
+ }
+
+ @override
+ void dispose() {
+ WidgetsBinding.instance.removeObserver(this);
+ super.dispose();
+ }
+
+ void _measure() {
+ if (!mounted) return;
+ final targetContext = widget.step.targetKey.currentContext;
+ if (targetContext == null) {
+ if (_targetRect != null) setState(() => _targetRect = null);
+ return;
+ }
+ final box = targetContext.findRenderObject();
+ if (box is! RenderBox || !box.hasSize) return;
+ final offset = box.localToGlobal(Offset.zero);
+ setState(() {
+ _targetRect = (offset & box.size).inflate(widget.step.inflate);
+ });
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final media = MediaQuery.of(context);
+ final theme = Theme.of(context);
+ final isDark = theme.brightness == Brightness.dark;
+ final screen = media.size;
+ final reduceMotion = media.disableAnimations;
+
+ final rect = _targetRect;
+ final bubble = _CoachMarkBubble(
+ step: widget.step,
+ isLast: widget.isLast,
+ index: widget.index,
+ total: widget.total,
+ onNext: widget.onNext,
+ onSkip: widget.onSkip,
+ );
+
+ final safe = CoachMarkPlacement.safeViewport(screen, media, isWeb: kIsWeb);
+ final anchored = CoachMarkPlacement.targetVisible(rect, safe);
+ final placeBelow =
+ anchored && rect != null && CoachMarkPlacement.placeBelow(rect, safe);
+
+ return Material(
+ type: MaterialType.transparency,
+ child: Stack(
+ children: [
+ // Scrim. Tapping anywhere advances — a coach mark that traps you
+ // until you find the right button is worse than the confusion it
+ // was meant to fix.
+ Positioned.fill(
+ child: Semantics(
+ button: true,
+ label: 'Dismiss tip',
+ child: GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onTap: widget.onNext,
+ child: CustomPaint(
+ painter: _SpotlightPainter(
+ target: rect,
+ color: Colors.black.withValues(alpha: isDark ? 0.72 : 0.55),
+ ),
+ ),
+ ),
+ ),
+ ),
+ if (rect != null)
+ Positioned(
+ left: rect.left,
+ top: rect.top,
+ width: rect.width,
+ height: rect.height,
+ child: IgnorePointer(
+ child: Container(
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(NoSusTheme.r12),
+ border: Border.all(
+ color: Colors.white.withValues(alpha: 0.9),
+ width: 1.5,
+ ),
+ ),
+ ),
+ ),
+ ),
+ Positioned(
+ left: safe.left,
+ right: screen.width - safe.right,
+ top: safe.top,
+ bottom: screen.height - safe.bottom,
+ child: Align(
+ alignment: !anchored
+ ? Alignment.center
+ : placeBelow
+ ? Alignment.topCenter
+ : Alignment.bottomCenter,
+ child: _animateBubble(bubble, reduceMotion),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _animateBubble(Widget bubble, bool reduceMotion) {
+ if (reduceMotion) return bubble;
+ return TweenAnimationBuilder(
+ tween: Tween(begin: 0, end: 1),
+ duration: const Duration(milliseconds: 220),
+ curve: Curves.easeOutCubic,
+ builder: (context, t, child) => Opacity(
+ opacity: t,
+ child: Transform.translate(
+ offset: Offset(0, (1 - t) * 8),
+ child: child,
+ ),
+ ),
+ child: bubble,
+ );
+ }
+}
+
+class _CoachMarkBubble extends StatelessWidget {
+ final CoachMarkStep step;
+ final bool isLast;
+ final int index;
+ final int total;
+ final VoidCallback onNext;
+ final VoidCallback onSkip;
+
+ const _CoachMarkBubble({
+ required this.step,
+ required this.isLast,
+ required this.index,
+ required this.total,
+ required this.onNext,
+ required this.onSkip,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ final isDark = theme.brightness == Brightness.dark;
+ final bg = isDark ? NoSusTheme.dCard : Colors.white;
+ final fg = isDark ? NoSusTheme.dText : NoSusTheme.lText;
+
+ final actions = Row(
+ mainAxisAlignment: MainAxisAlignment.end,
+ children: [
+ if (!isLast)
+ TextButton(
+ onPressed: onSkip,
+ child: Text(
+ 'SKIP',
+ style: TextStyle(
+ fontSize: 11,
+ fontWeight: FontWeight.bold,
+ letterSpacing: 1.0,
+ color: fg.withValues(alpha: 0.55),
+ ),
+ ),
+ ),
+ const SizedBox(width: NoSusTheme.s8),
+ FilledButton(
+ onPressed: onNext,
+ style: FilledButton.styleFrom(
+ backgroundColor: fg,
+ foregroundColor: isDark ? Colors.black : Colors.white,
+ padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(NoSusTheme.r12),
+ ),
+ ),
+ child: Text(
+ isLast ? 'DONE' : 'NEXT',
+ style: const TextStyle(
+ fontSize: 11,
+ fontWeight: FontWeight.bold,
+ letterSpacing: 1.0,
+ ),
+ ),
+ ),
+ ],
+ );
+
+ final copy = Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ if (total > 1)
+ Padding(
+ padding: const EdgeInsets.only(bottom: NoSusTheme.s8),
+ child: Text(
+ 'TIP ${index + 1} OF $total',
+ style: theme.textTheme.labelLarge?.copyWith(
+ fontSize: 9,
+ letterSpacing: 1.5,
+ color: fg.withValues(alpha: 0.45),
+ ),
+ ),
+ ),
+ Text(
+ step.title,
+ style: theme.textTheme.titleMedium?.copyWith(
+ fontSize: 16,
+ fontWeight: FontWeight.w800,
+ color: fg,
+ ),
+ ),
+ const SizedBox(height: NoSusTheme.s8),
+ Text(
+ step.body,
+ style: theme.textTheme.bodyMedium?.copyWith(
+ fontSize: 13,
+ height: 1.55,
+ color: fg.withValues(alpha: 0.75),
+ ),
+ ),
+ ],
+ );
+
+ return Semantics(
+ liveRegion: true,
+ // The whole bubble reads as one announcement; the buttons below carry
+ // their own labels.
+ container: true,
+ child: Container(
+ decoration: BoxDecoration(
+ color: bg,
+ borderRadius: BorderRadius.circular(NoSusTheme.r16),
+ border: Border.all(color: fg.withValues(alpha: 0.12), width: 0.75),
+ boxShadow: [
+ BoxShadow(
+ color: Colors.black.withValues(alpha: 0.28),
+ blurRadius: 24,
+ offset: const Offset(0, 8),
+ ),
+ ],
+ ),
+ child: LayoutBuilder(
+ builder: (context, constraints) {
+ // Keep DONE/SKIP pinned even when the copy is taller than the
+ // remaining safe viewport — the previous single scroll view let
+ // the action row clip under the Android home indicator.
+ const actionsReserve = 76.0;
+ final maxCopyHeight = constraints.hasBoundedHeight
+ ? math.max(64.0, constraints.maxHeight - actionsReserve)
+ : double.infinity;
+ return Padding(
+ padding: const EdgeInsets.fromLTRB(
+ NoSusTheme.s24,
+ NoSusTheme.s24,
+ NoSusTheme.s24,
+ NoSusTheme.s16,
+ ),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ ConstrainedBox(
+ constraints: BoxConstraints(maxHeight: maxCopyHeight),
+ child: SingleChildScrollView(child: copy),
+ ),
+ const SizedBox(height: NoSusTheme.s16),
+ actions,
+ ],
+ ),
+ );
+ },
+ ),
+ ),
+ );
+ }
+}
+
+/// Fills the screen with [color], minus a rounded hole over [target].
+class _SpotlightPainter extends CustomPainter {
+ final Rect? target;
+ final Color color;
+
+ const _SpotlightPainter({required this.target, required this.color});
+
+ @override
+ void paint(Canvas canvas, Size size) {
+ final screen = Offset.zero & size;
+ final paint = Paint()..color = color;
+
+ if (target == null) {
+ canvas.drawRect(screen, paint);
+ return;
+ }
+
+ final hole = Path()
+ ..addRRect(
+ RRect.fromRectAndRadius(target!, const Radius.circular(NoSusTheme.r12)),
+ );
+ final scrim = Path.combine(
+ PathOperation.difference,
+ Path()..addRect(screen),
+ hole,
+ );
+ canvas.drawPath(scrim, paint);
+ }
+
+ @override
+ bool shouldRepaint(_SpotlightPainter old) =>
+ old.target != target || old.color != color;
+}
diff --git a/app/lib/components/experiment_frame.dart b/app/lib/components/experiment_frame.dart
new file mode 100644
index 0000000..04d5887
--- /dev/null
+++ b/app/lib/components/experiment_frame.dart
@@ -0,0 +1,28 @@
+import 'package:flutter/material.dart';
+
+class ExperimentFrame extends StatelessWidget {
+ const ExperimentFrame({super.key, required this.child});
+ final Widget child;
+ @override
+ Widget build(BuildContext context) => Column(
+ children: [
+ Material(
+ color: Theme.of(context).colorScheme.surfaceContainerHighest,
+ child: const SafeArea(
+ bottom: false,
+ child: Padding(
+ padding: EdgeInsets.symmetric(horizontal: 12, vertical: 7),
+ child: Center(
+ child: Text(
+ 'NO SUS · experiment build · use harmless test notes only',
+ textAlign: TextAlign.center,
+ style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
+ ),
+ ),
+ ),
+ ),
+ ),
+ Expanded(child: child),
+ ],
+ );
+}
diff --git a/app/lib/components/floating_nav.dart b/app/lib/components/floating_nav.dart
new file mode 100644
index 0000000..b2741fa
--- /dev/null
+++ b/app/lib/components/floating_nav.dart
@@ -0,0 +1,168 @@
+import 'dart:ui';
+import 'package:flutter/material.dart';
+import '../theme.dart';
+
+class FloatingNav extends StatelessWidget {
+ final int currentIndex;
+ final ValueChanged onTap;
+
+ const FloatingNav({
+ super.key,
+ required this.currentIndex,
+ required this.onTap,
+ });
+
+ static bool isCompact(BuildContext context) =>
+ MediaQuery.sizeOf(context).width < 480;
+
+ static double contentClearance(BuildContext context) {
+ return isCompact(context) ? 92 : 110;
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ final isDark = theme.brightness == Brightness.dark;
+ final compact = isCompact(context);
+ final horizontalInset = compact ? NoSusTheme.s12 : NoSusTheme.s24;
+ final bottomInset = compact ? NoSusTheme.s12 : NoSusTheme.s24;
+ final height = compact ? 64.0 : 72.0;
+
+ final List items = [
+ NavTabItem(
+ icon: Icons.dashboard_outlined,
+ selectedIcon: Icons.dashboard,
+ label: 'Workspace',
+ ),
+ NavTabItem(
+ icon: Icons.shield_outlined,
+ selectedIcon: Icons.shield,
+ label: 'Vault',
+ ),
+ NavTabItem(
+ icon: Icons.remove_red_eye_outlined,
+ selectedIcon: Icons.remove_red_eye,
+ label: 'Study Desk',
+ ),
+ NavTabItem(
+ icon: Icons.history_edu_outlined,
+ selectedIcon: Icons.history_edu,
+ label: 'Audit Log',
+ ),
+ NavTabItem(
+ icon: Icons.group_outlined,
+ selectedIcon: Icons.group,
+ label: 'Groups',
+ ),
+ ];
+
+ return Align(
+ alignment: Alignment.bottomCenter,
+ child: Padding(
+ padding: const EdgeInsets.only(left: 0, right: 0, bottom: 0),
+ child: Padding(
+ padding: EdgeInsets.only(
+ left: horizontalInset,
+ right: horizontalInset,
+ bottom: bottomInset,
+ ),
+ child: ClipRRect(
+ borderRadius: BorderRadius.circular(NoSusTheme.r24),
+ child: BackdropFilter(
+ filter: ImageFilter.blur(sigmaX: 12.0, sigmaY: 12.0),
+ child: Container(
+ height: height,
+ decoration: BoxDecoration(
+ color: isDark
+ ? NoSusTheme.dCard.withValues(alpha: 0.7)
+ : Colors.white.withValues(alpha: 0.8),
+ borderRadius: BorderRadius.circular(NoSusTheme.r24),
+ border: Border.all(
+ color: theme.colorScheme.outline,
+ width: 1.2,
+ ),
+ ),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceEvenly,
+ children: List.generate(items.length, (index) {
+ final item = items[index];
+ final isSelected = currentIndex == index;
+
+ return Expanded(
+ child: Semantics(
+ button: true,
+ selected: isSelected,
+ label: '${item.label} tab',
+ child: GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onTap: () => onTap(index),
+ child: AnimatedContainer(
+ duration: const Duration(milliseconds: 250),
+ curve: Curves.easeInOut,
+ alignment: Alignment.center,
+ child: ExcludeSemantics(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ AnimatedScale(
+ duration: const Duration(milliseconds: 200),
+ scale: isSelected ? 1.12 : 1.0,
+ child: Icon(
+ isSelected
+ ? item.selectedIcon
+ : item.icon,
+ color: isSelected
+ ? theme.colorScheme.onSurface
+ : theme.colorScheme.onSurface
+ .withValues(alpha: 0.4),
+ size: 24,
+ ),
+ ),
+ if (!compact) ...[
+ const SizedBox(height: NoSusTheme.s4),
+ Text(
+ item.label,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: theme.textTheme.labelLarge
+ ?.copyWith(
+ fontSize: 10,
+ fontWeight: isSelected
+ ? FontWeight.w600
+ : FontWeight.normal,
+ color: isSelected
+ ? theme.colorScheme.onSurface
+ : theme.colorScheme.onSurface
+ .withValues(alpha: 0.4),
+ ),
+ ),
+ ],
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class NavTabItem {
+ final IconData icon;
+ final IconData selectedIcon;
+ final String label;
+
+ NavTabItem({
+ required this.icon,
+ required this.selectedIcon,
+ required this.label,
+ });
+}
diff --git a/app/lib/components/offline_banner.dart b/app/lib/components/offline_banner.dart
new file mode 100644
index 0000000..5c6540e
--- /dev/null
+++ b/app/lib/components/offline_banner.dart
@@ -0,0 +1,71 @@
+import 'package:flutter/material.dart';
+import '../services/supabase_service.dart';
+import '../theme.dart';
+
+/// Dismissible strip shown when [SupabaseService.isReachable] is false — a
+/// lighter-weight alternative to full-screen dead-end blocks (see
+/// AuthGate's unreachable state) or the purely cosmetic offline dot
+/// previously used on its own in workspace_tab.dart.
+///
+/// [isReachable] is a point-in-time flag set once during startup (see
+/// SupabaseService.initialize) rather than a live connectivity stream, so
+/// this banner's state is read once per build like every other consumer of
+/// that flag in the app — it does not re-check reachability itself.
+class OfflineBanner extends StatefulWidget {
+ const OfflineBanner({super.key});
+
+ @override
+ State createState() => _OfflineBannerState();
+}
+
+class _OfflineBannerState extends State {
+ bool _dismissed = false;
+
+ @override
+ Widget build(BuildContext context) {
+ if (_dismissed || SupabaseService.instance.isReachable) {
+ return const SizedBox.shrink();
+ }
+ final theme = Theme.of(context);
+ final isDark = theme.brightness == Brightness.dark;
+
+ return Container(
+ width: double.infinity,
+ padding: const EdgeInsets.symmetric(
+ horizontal: NoSusTheme.s16,
+ vertical: NoSusTheme.s12,
+ ),
+ decoration: BoxDecoration(
+ color: isDark ? NoSusTheme.dCard : NoSusTheme.lCard,
+ border: Border(
+ bottom: BorderSide(color: theme.colorScheme.outline, width: 1.0),
+ ),
+ ),
+ child: Row(
+ children: [
+ Icon(
+ Icons.wifi_off_outlined,
+ size: 16,
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
+ ),
+ const SizedBox(width: NoSusTheme.s8),
+ Expanded(
+ child: Text(
+ "You're offline — showing the last saved data.",
+ style: theme.textTheme.bodyMedium?.copyWith(fontSize: 12),
+ ),
+ ),
+ IconButton(
+ onPressed: () => setState(() => _dismissed = true),
+ tooltip: 'Dismiss offline notice',
+ icon: Icon(
+ Icons.close,
+ size: 16,
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.4),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/app/lib/components/secure_viewer/blur_reveal_layer.dart b/app/lib/components/secure_viewer/blur_reveal_layer.dart
new file mode 100644
index 0000000..9ffe481
--- /dev/null
+++ b/app/lib/components/secure_viewer/blur_reveal_layer.dart
@@ -0,0 +1,227 @@
+import 'dart:async';
+import 'dart:ui' as ui;
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'models/viewer_config.dart';
+import '../../core/mascot/mascot_controller.dart';
+import '../../core/mascot/mascot_state.dart';
+import '../../core/mascot/mascot_view.dart';
+
+/// Core touch-blur engine for the NO SUS secure document viewer.
+///
+/// Obscures the entire canvas behind a blur/mask overlay by default.
+/// When the user touches the screen, the entire blur layer animates to clear (unblurred),
+/// allowing view of the whole screen. When the finger lifts, it blurs again.
+class BlurRevealLayer extends ConsumerStatefulWidget {
+ /// The protected document content. Wrapped in [RepaintBoundary] internally.
+ final Widget child;
+
+ /// An always-visible overlay that sits above the blur (e.g., watermark).
+ final Widget? overlay;
+
+ /// Animation + blur behavior configuration.
+ final ViewerConfig config;
+
+ /// Whether to show the "TOUCH TO REVEAL" hint when content is blurred.
+ final bool showHint;
+
+ /// Whether the layer starts revealed (false) or concealed (true, the
+ /// existing default). Web viewers that don't want the sender's default
+ /// touch-to-reveal blur still pass a [forceConcealSignal] so a detected
+ /// security event can conceal on demand — this lets them start revealed.
+ final bool initiallyConcealed;
+
+ /// Emits to force an immediate conceal regardless of current touch state
+ /// — e.g. [WebSecurityGuard] detected DevTools opening or repeated tab
+ /// hiding. Does not change [showHint] or block the user from touching to
+ /// reveal again afterward; this is a reactive deterrent, not a lock.
+ final Stream? forceConcealSignal;
+
+ const BlurRevealLayer({
+ super.key,
+ required this.child,
+ this.overlay,
+ this.config = const ViewerConfig(),
+ this.showHint = true,
+ this.initiallyConcealed = true,
+ this.forceConcealSignal,
+ });
+
+ @override
+ ConsumerState createState() => _BlurRevealLayerState();
+}
+
+class _BlurRevealLayerState extends ConsumerState
+ with SingleTickerProviderStateMixin {
+ late final AnimationController _controller;
+ StreamSubscription? _forceConcealSub;
+
+ // Number of currently active pointers (supports multi-touch tracking)
+ int _activePointers = 0;
+
+ @override
+ void initState() {
+ super.initState();
+
+ _controller = AnimationController(
+ value: widget.initiallyConcealed ? 1.0 : 0.0,
+ vsync: this,
+ );
+
+ _forceConcealSub = widget.forceConcealSignal?.listen((_) {
+ if (!mounted) return;
+ _controller.animateTo(
+ 1.0,
+ duration: widget.config.concealDuration,
+ curve: widget.config.concealCurve,
+ );
+ ref.read(noxMascotProvider.notifier).play(MascotMood.alert);
+ });
+
+ if (widget.initiallyConcealed) {
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ // Blurred on open — Nox is guarding this document.
+ if (mounted) ref.read(noxMascotProvider.notifier).play(MascotMood.protect);
+ });
+ }
+ }
+
+ @override
+ void dispose() {
+ _forceConcealSub?.cancel();
+ _controller.dispose();
+ super.dispose();
+ }
+
+ // ─── Pointer Event Handlers ───────────────────────────────────────────────
+
+ void _onPointerDown(PointerDownEvent event) {
+ _activePointers++;
+
+ if (_activePointers == 1) {
+ _controller.animateTo(
+ 0.0,
+ duration: widget.config.revealDuration,
+ curve: widget.config.revealCurve,
+ );
+ // The reveal tap is the exact moment Nox nods it through.
+ ref.read(noxMascotProvider.notifier).play(MascotMood.approve);
+ }
+ }
+
+ void _onPointerUp(PointerEvent event) {
+ _activePointers = (_activePointers - 1).clamp(0, 20);
+
+ if (_activePointers == 0) {
+ _controller.animateTo(
+ 1.0,
+ duration: widget.config.concealDuration,
+ curve: widget.config.concealCurve,
+ );
+ ref.read(noxMascotProvider.notifier).play(MascotMood.protect);
+ }
+ }
+
+ // ─── Build ────────────────────────────────────────────────────────────────
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ final isDark = theme.brightness == Brightness.dark;
+
+ return Listener(
+ behavior: HitTestBehavior.translucent,
+ onPointerDown: _onPointerDown,
+ onPointerUp: _onPointerUp,
+ onPointerCancel: _onPointerUp, // System interruptions = conceal
+ child: Stack(
+ fit: StackFit.expand,
+ children: [
+ // ── Layer 1: Protected document content ──────────────────────────
+ RepaintBoundary(child: widget.child),
+
+ // ── Layer 2: Animated blur overlay ───────────────────────────────
+ IgnorePointer(
+ child: AnimatedBuilder(
+ animation: _controller,
+ builder: (context, _) {
+ final sigma = _controller.value * widget.config.maxBlurSigma;
+ if (sigma < 0.05) {
+ return const SizedBox.shrink();
+ }
+
+ final tintAlpha = _controller.value * (isDark ? 0.25 : 0.15);
+
+ return BackdropFilter(
+ filter: ui.ImageFilter.blur(
+ sigmaX: sigma,
+ sigmaY: sigma,
+ tileMode: ui.TileMode.clamp,
+ ),
+ child: ColoredBox(
+ color: Colors.black.withValues(alpha: tintAlpha),
+ ),
+ );
+ },
+ ),
+ ),
+
+ // ── Layer 3: Always-visible overlay (watermark) ──────────────────
+ if (widget.overlay != null) widget.overlay!,
+
+ // ── Layer 4: Touch hint ───────────────────────────────────────────
+ if (widget.showHint) _TouchRevealHint(animation: _controller),
+ ],
+ ),
+ );
+ }
+}
+
+// ─── Touch Reveal Hint ───────────────────────────────────────────────────────
+
+class _TouchRevealHint extends StatelessWidget {
+ final Animation animation;
+
+ const _TouchRevealHint({required this.animation});
+
+ @override
+ Widget build(BuildContext context) {
+ final isDark = Theme.of(context).brightness == Brightness.dark;
+ final textColor = isDark ? Colors.white : Colors.black;
+
+ return IgnorePointer(
+ child: FadeTransition(
+ opacity: CurvedAnimation(
+ parent: animation,
+ curve: const Interval(0.4, 1.0, curve: Curves.easeIn),
+ ),
+ child: Center(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ MascotView(
+ character: MascotCharacter.nox,
+ size: 28,
+ fallback: Icon(
+ Icons.touch_app_outlined,
+ size: 28,
+ color: textColor.withValues(alpha: 0.35),
+ ),
+ ),
+ const SizedBox(height: 10),
+ Text(
+ 'TOUCH TO REVEAL',
+ style: TextStyle(
+ color: textColor.withValues(alpha: 0.35),
+ fontSize: 11,
+ fontWeight: FontWeight.w600,
+ letterSpacing: 2.5,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/app/lib/components/secure_viewer/models/viewer_config.dart b/app/lib/components/secure_viewer/models/viewer_config.dart
new file mode 100644
index 0000000..22138ce
--- /dev/null
+++ b/app/lib/components/secure_viewer/models/viewer_config.dart
@@ -0,0 +1,40 @@
+import 'package:flutter/material.dart';
+
+/// Immutable configuration for the blur-reveal animation behavior and appearance.
+@immutable
+class ViewerConfig {
+ /// Maximum Gaussian blur sigma when no finger is touching.
+ /// 18–24 is visually opaque while remaining GPU-performant.
+ final double maxBlurSigma;
+
+ /// Duration of the reveal animation (finger down → content clears).
+ /// Keep ≤ 150ms for a snappy, responsive feel.
+ final Duration revealDuration;
+
+ /// Duration of the conceal animation (finger up → content blurs).
+ /// Slightly longer than reveal gives a satisfying "security seal" feel.
+ final Duration concealDuration;
+
+ /// Easing curve for the reveal animation.
+ final Curve revealCurve;
+
+ /// Easing curve for the conceal animation.
+ final Curve concealCurve;
+
+ /// Optional tint applied over the blur layer (e.g., a very subtle dark wash).
+ /// Set to null for a pure Gaussian blur with no color overlay.
+ final Color? backdropTint;
+
+ /// The radius of the spyglass viewport (clear touch window).
+ final double spyglassRadius;
+
+ const ViewerConfig({
+ this.maxBlurSigma = 22.0,
+ this.revealDuration = const Duration(milliseconds: 120),
+ this.concealDuration = const Duration(milliseconds: 220),
+ this.revealCurve = Curves.easeOut,
+ this.concealCurve = Curves.easeIn,
+ this.backdropTint,
+ this.spyglassRadius = 75.0,
+ });
+}
diff --git a/app/lib/components/secure_viewer/models/watermark_config.dart b/app/lib/components/secure_viewer/models/watermark_config.dart
new file mode 100644
index 0000000..d1e0e59
--- /dev/null
+++ b/app/lib/components/secure_viewer/models/watermark_config.dart
@@ -0,0 +1,107 @@
+import 'package:flutter/foundation.dart';
+
+/// Immutable configuration for the diagonal repeating watermark overlay.
+/// All fields participate in equality — changing any field triggers a repaint.
+@immutable
+class WatermarkConfig {
+ final String name;
+ final String role;
+ final String email;
+ final String timestamp;
+
+ /// Opacity of the watermark text. Subtle but always readable.
+ /// Range: 0.0 – 1.0. Default 0.07 (7%).
+ final double opacity;
+
+ /// Base font size for secondary lines (email, phone, timestamp).
+ final double fontSize;
+
+ /// Horizontal spacing between watermark tile columns (in logical pixels).
+ /// Wider = sparser grid, fewer repeats on screen.
+ final double tileSpacingX;
+
+ /// Vertical GAP between watermark blocks, on top of the block's own text
+ /// height (in logical pixels). Wider = sparser grid.
+ final double tileSpacingY;
+
+ /// Rotation angle of the watermark grid in degrees (negative = tilts left).
+ final double angleDegrees;
+
+ const WatermarkConfig({
+ required this.name,
+ required this.role,
+ required this.email,
+ required this.timestamp,
+ this.opacity = 0.12,
+ this.fontSize = 12.0,
+ this.tileSpacingX = 340.0,
+ this.tileSpacingY = 240.0,
+ this.angleDegrees = -28.0,
+ });
+
+ /// Returns a copy scaled for the risk engine's watermark_intensity
+ /// (normal/increased/maximum — see
+ /// supabase/migrations/20260710030000_risk_engine.sql and
+ /// RiskEngineService). Denser + more opaque watermarking is one of the
+ /// engine's few automatic responses that's visible to the viewer rather
+ /// than silent — deliberately so, since a would-be leaker seeing heavier
+ /// tracing on-screen is itself a deterrent.
+ WatermarkConfig scaledForIntensity(String intensity) {
+ switch (intensity) {
+ case 'maximum':
+ return WatermarkConfig(
+ name: name,
+ role: role,
+ email: email,
+ timestamp: timestamp,
+ opacity: (opacity * 2.2).clamp(0.0, 0.5),
+ fontSize: fontSize,
+ tileSpacingX: tileSpacingX * 0.45,
+ tileSpacingY: tileSpacingY * 0.45,
+ angleDegrees: angleDegrees,
+ );
+ case 'increased':
+ return WatermarkConfig(
+ name: name,
+ role: role,
+ email: email,
+ timestamp: timestamp,
+ opacity: (opacity * 1.5).clamp(0.0, 0.35),
+ fontSize: fontSize,
+ tileSpacingX: tileSpacingX * 0.7,
+ tileSpacingY: tileSpacingY * 0.7,
+ angleDegrees: angleDegrees,
+ );
+ default:
+ return this;
+ }
+ }
+
+ @override
+ bool operator ==(Object other) =>
+ identical(this, other) ||
+ other is WatermarkConfig &&
+ runtimeType == other.runtimeType &&
+ name == other.name &&
+ role == other.role &&
+ email == other.email &&
+ timestamp == other.timestamp &&
+ opacity == other.opacity &&
+ fontSize == other.fontSize &&
+ tileSpacingX == other.tileSpacingX &&
+ tileSpacingY == other.tileSpacingY &&
+ angleDegrees == other.angleDegrees;
+
+ @override
+ int get hashCode => Object.hash(
+ name,
+ role,
+ email,
+ timestamp,
+ opacity,
+ fontSize,
+ tileSpacingX,
+ tileSpacingY,
+ angleDegrees,
+ );
+}
diff --git a/app/lib/components/secure_viewer/painters/watermark_painter.dart b/app/lib/components/secure_viewer/painters/watermark_painter.dart
new file mode 100644
index 0000000..64cb97a
--- /dev/null
+++ b/app/lib/components/secure_viewer/painters/watermark_painter.dart
@@ -0,0 +1,118 @@
+import 'dart:math' as math;
+import 'package:flutter/material.dart';
+import '../models/watermark_config.dart';
+
+/// Renders a sparse, diagonally-repeating watermark grid across the full canvas.
+///
+/// Contrast is DOCUMENT-adaptive, not app-theme-adaptive: each glyph is drawn
+/// twice — a translucent dark outline (reads on light document pages) plus a
+/// translucent light fill on top (reads on dark document pages) — so the same
+/// watermark stays legible regardless of the underlying PDF/image background,
+/// independent of whether the app itself is in light or dark mode.
+///
+/// Performance profile:
+/// - Wrapped in RepaintBoundary → repaints ONLY when [shouldRepaint] returns true
+/// - All TextPainter objects are created and disposed within a single [paint] call
+/// - Canvas rotate + translate = single GPU matrix multiplication (essentially free)
+///
+/// Security note: This watermark provides LEGAL traceability and VISUAL deterrence.
+/// It does NOT cryptographically prevent screen capture by an external camera.
+class WatermarkPainter extends CustomPainter {
+ final WatermarkConfig config;
+
+ const WatermarkPainter({required this.config});
+
+ @override
+ void paint(Canvas canvas, Size size) {
+ // Only the essentials: who, and when. ("CONFIDENTIAL" / role were dropped
+ // — decorative bulk, not traceability-essential — to cut text density.)
+ final identity =
+ config.name.trim().isEmpty || config.name.trim().toLowerCase() == config.email.trim().toLowerCase()
+ ? config.email
+ : '${config.name} · ${config.email}';
+ final lines = <(String, double, FontWeight)>[
+ (identity, config.fontSize + 0.5, FontWeight.w600),
+ (config.timestamp, config.fontSize - 1.0, FontWeight.w400),
+ ];
+
+ const intraLineGap = 3.5;
+ final lineHeight = config.fontSize + intraLineGap;
+
+ // Layout each line once; render as an outline pass + fill pass on top so
+ // it contrasts against both light and dark document backgrounds.
+ final outlinePaint = Paint()
+ ..color = Colors.black.withValues(alpha: config.opacity)
+ ..style = PaintingStyle.stroke
+ ..strokeWidth = 2.2;
+ final fillColor = Colors.white.withValues(alpha: config.opacity * 1.4);
+
+ final outlinePainters = [];
+ final fillPainters = [];
+ for (final line in lines) {
+ final baseStyle = TextStyle(
+ fontSize: line.$2,
+ fontWeight: line.$3,
+ letterSpacing: 0.6,
+ height: 1.15,
+ );
+ outlinePainters.add(TextPainter(
+ text: TextSpan(text: line.$1, style: baseStyle.copyWith(foreground: outlinePaint)),
+ textDirection: TextDirection.ltr,
+ )..layout());
+ fillPainters.add(TextPainter(
+ text: TextSpan(text: line.$1, style: baseStyle.copyWith(color: fillColor)),
+ textDirection: TextDirection.ltr,
+ )..layout());
+ }
+
+ // Block metrics — sparse: a generous gap between blocks, controlled by
+ // tileSpacingY (previously unused; now the primary density knob).
+ final blockH = lines.length * lineHeight;
+ final rowStep = blockH + config.tileSpacingY;
+
+ // Canvas diagonal determines tile count needed to cover all corners after rotation.
+ final diagonal = math.sqrt(size.width * size.width + size.height * size.height);
+ final cols = (diagonal / config.tileSpacingX).ceil() + 2;
+ final rows = (diagonal / rowStep).ceil() + 2;
+
+ final angleRad = config.angleDegrees * math.pi / 180.0;
+
+ canvas.save();
+ // Rotate around the canvas center so the grid appears centered.
+ canvas.translate(size.width / 2.0, size.height / 2.0);
+ canvas.rotate(angleRad);
+
+ for (int row = -rows; row <= rows; row++) {
+ for (int col = -cols; col <= cols; col++) {
+ canvas.save();
+ canvas.translate(col * config.tileSpacingX, row * rowStep);
+
+ double yOffset = 0.0;
+ for (var i = 0; i < outlinePainters.length; i++) {
+ final outline = outlinePainters[i];
+ final fill = fillPainters[i];
+ outline.paint(canvas, Offset(-outline.width / 2.0, yOffset));
+ fill.paint(canvas, Offset(-fill.width / 2.0, yOffset));
+ yOffset += lineHeight;
+ }
+
+ canvas.restore();
+ }
+ }
+
+ canvas.restore();
+
+ for (final tp in outlinePainters) {
+ tp.dispose();
+ }
+ for (final tp in fillPainters) {
+ tp.dispose();
+ }
+ }
+
+ @override
+ bool shouldRepaint(WatermarkPainter oldDelegate) => oldDelegate.config != config;
+
+ @override
+ bool shouldRebuildSemantics(WatermarkPainter oldDelegate) => false;
+}
diff --git a/app/lib/components/secure_viewer/secure_document_viewer.dart b/app/lib/components/secure_viewer/secure_document_viewer.dart
new file mode 100644
index 0000000..e73be2f
--- /dev/null
+++ b/app/lib/components/secure_viewer/secure_document_viewer.dart
@@ -0,0 +1,100 @@
+import 'package:flutter/material.dart';
+import 'models/watermark_config.dart';
+import 'models/viewer_config.dart';
+import 'blur_reveal_layer.dart';
+import 'watermark_overlay.dart';
+
+/// Public API widget for the NO SUS secure document viewer system.
+///
+/// Composes [BlurRevealLayer] and [WatermarkOverlay] into a single, clean
+/// surface. Callers provide [child] (any scrollable document widget) plus
+/// the watermark and viewer configurations.
+///
+/// ## Usage
+///
+/// ```dart
+/// SecureDocumentViewer(
+/// watermarkConfig: WatermarkConfig(
+/// email: 'user@example.com',
+/// phone: '+91 98765 43210',
+/// timestamp: '2026-06-09 13:06 IST',
+/// ),
+/// child: SingleChildScrollView(
+/// child: MarkdownBody(data: markdownContent),
+/// ),
+/// )
+/// ```
+///
+/// ## Security notes
+///
+/// - Content is blurred by default; cleared only while finger touches screen.
+/// - Watermark persists at all blur states — always present on external camera footage.
+/// - Screenshot blocking requires platform-level integration (FLAG_SECURE on Android).
+/// - External cameras CANNOT be blocked; this system provides friction + traceability.
+class SecureDocumentViewer extends StatelessWidget {
+ /// The document content to protect. Typically a [SingleChildScrollView]
+ /// containing text, a PDF widget, or a [ListView] of pages.
+ final Widget child;
+
+ /// Watermark configuration: user identity + timestamp.
+ final WatermarkConfig watermarkConfig;
+
+ /// Blur behavior and animation configuration.
+ final ViewerConfig viewerConfig;
+
+ /// Whether to show the "TOUCH TO REVEAL" interaction hint.
+ final bool showHint;
+
+ /// Whether the touch-to-reveal blur layer is enabled.
+ final bool touchToRevealEnabled;
+
+ /// Whether the watermark overlay layer is enabled.
+ final bool watermarkEnabled;
+
+ /// Forces an immediate conceal (blur) regardless of [touchToRevealEnabled]
+ /// — wired to [WebSecurityGuard]'s detections on the web viewers (DevTools
+ /// opened, repeated tab-hiding). When this is provided, the blur layer is
+ /// always present so it has something to conceal, even if the sender
+ /// didn't request touch-to-reveal-by-default; it just starts revealed in
+ /// that case (see [BlurRevealLayer.initiallyConcealed]) rather than
+ /// blurred until the first tap.
+ final Stream? forceConcealSignal;
+
+ const SecureDocumentViewer({
+ super.key,
+ required this.child,
+ required this.watermarkConfig,
+ this.viewerConfig = const ViewerConfig(),
+ this.showHint = true,
+ this.touchToRevealEnabled = true,
+ this.watermarkEnabled = true,
+ this.forceConcealSignal,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ if (touchToRevealEnabled || forceConcealSignal != null) {
+ return BlurRevealLayer(
+ config: viewerConfig,
+ showHint: showHint && touchToRevealEnabled,
+ initiallyConcealed: touchToRevealEnabled,
+ forceConcealSignal: forceConcealSignal,
+ overlay: watermarkEnabled
+ ? WatermarkOverlay(config: watermarkConfig)
+ : const SizedBox.shrink(),
+ child: child,
+ );
+ }
+
+ return Stack(
+ fit: StackFit.expand,
+ children: [
+ // ── Layer 1: Protected document content ──────────────────────────
+ RepaintBoundary(child: child),
+ // ── Layer 2: Always-visible overlay (watermark) ──────────────────
+ if (watermarkEnabled) WatermarkOverlay(config: watermarkConfig),
+ ],
+ );
+ }
+}
+
diff --git a/app/lib/components/secure_viewer/watermark_overlay.dart b/app/lib/components/secure_viewer/watermark_overlay.dart
new file mode 100644
index 0000000..83fd7d7
--- /dev/null
+++ b/app/lib/components/secure_viewer/watermark_overlay.dart
@@ -0,0 +1,33 @@
+import 'package:flutter/material.dart';
+import 'models/watermark_config.dart';
+import 'painters/watermark_painter.dart';
+
+/// A stateless overlay that renders the [WatermarkPainter] over its parent.
+///
+/// Isolation guarantees:
+/// - [RepaintBoundary]: this layer never repaints when the document scrolls
+/// or when the blur animation runs.
+/// - [IgnorePointer]: events (touch, scroll) pass through transparently.
+/// - [SizedBox.expand]: ensures CustomPaint fills the full available space.
+///
+/// Contrast is document-adaptive (dark outline + light fill on every glyph),
+/// not app-theme-adaptive — the app's own light/dark mode has no bearing on
+/// what color the PDF/image underneath actually is, so there is nothing to
+/// resolve from [Theme.of(context)] here.
+class WatermarkOverlay extends StatelessWidget {
+ final WatermarkConfig config;
+
+ const WatermarkOverlay({super.key, required this.config});
+
+ @override
+ Widget build(BuildContext context) {
+ return RepaintBoundary(
+ child: IgnorePointer(
+ child: CustomPaint(
+ painter: WatermarkPainter(config: config),
+ child: const SizedBox.expand(),
+ ),
+ ),
+ );
+ }
+}
diff --git a/app/lib/components/shimmer_box.dart b/app/lib/components/shimmer_box.dart
new file mode 100644
index 0000000..da956e2
--- /dev/null
+++ b/app/lib/components/shimmer_box.dart
@@ -0,0 +1,108 @@
+import 'package:flutter/material.dart';
+
+/// Shared shimmer-sweep animation driver — the 1200ms color-sweep loop
+/// originally hand-rolled inside `GroupCardSkeleton`
+/// (lib/features/groups/widgets/group_card.dart), extracted so every
+/// skeleton loader in the app reuses one animation controller
+/// implementation instead of each hand-rolling its own.
+class ShimmerSweep extends StatefulWidget {
+ final Widget Function(BuildContext context, double shimmerValue) builder;
+
+ const ShimmerSweep({super.key, required this.builder});
+
+ @override
+ State createState() => _ShimmerSweepState();
+}
+
+class _ShimmerSweepState extends State
+ with SingleTickerProviderStateMixin {
+ late final AnimationController _controller;
+
+ @override
+ void initState() {
+ super.initState();
+ _controller = AnimationController(
+ vsync: this,
+ duration: const Duration(milliseconds: 1200),
+ )..repeat();
+ }
+
+ @override
+ void dispose() {
+ _controller.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return AnimatedBuilder(
+ animation: _controller,
+ builder: (context, _) => widget.builder(context, _controller.value),
+ );
+ }
+}
+
+/// A single shimmering placeholder block — generalizes the `_Bone` widget
+/// from `GroupCardSkeleton`. Sweeps a gradient highlight left-to-right on a
+/// continuous 1200ms loop, theme-aware for light/dark.
+class ShimmerBox extends StatelessWidget {
+ final double width;
+ final double height;
+ final double radius;
+
+ const ShimmerBox({
+ super.key,
+ required this.width,
+ required this.height,
+ this.radius = 4,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final isDark = Theme.of(context).brightness == Brightness.dark;
+ final base = isDark ? const Color(0xFF1E1E1E) : const Color(0xFFEEEEEC);
+ final highlight =
+ isDark ? const Color(0xFF2A2A2A) : const Color(0xFFFFFFFF);
+
+ return ShimmerSweep(
+ builder: (context, v) => Container(
+ width: width,
+ height: height,
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(radius),
+ gradient: LinearGradient(
+ begin: Alignment(-1.5 + v * 3, 0),
+ end: Alignment(-0.5 + v * 3, 0),
+ colors: [base, highlight, base],
+ stops: const [0.0, 0.5, 1.0],
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+/// Generalizes `groups_screen.dart`'s `_SkeletonList` — a non-scrollable
+/// stack of placeholder items shown while an `AsyncValue` is loading.
+class ShimmerListSkeleton extends StatelessWidget {
+ final int itemCount;
+ final WidgetBuilder itemBuilder;
+ final double spacing;
+
+ const ShimmerListSkeleton({
+ super.key,
+ this.itemCount = 3,
+ required this.itemBuilder,
+ this.spacing = 12,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return ListView.separated(
+ physics: const NeverScrollableScrollPhysics(),
+ itemCount: itemCount,
+ separatorBuilder: (context, index) => SizedBox(height: spacing),
+ itemBuilder: (context, index) => itemBuilder(context),
+ );
+ }
+}
diff --git a/app/lib/components/spyglass_viewer.dart b/app/lib/components/spyglass_viewer.dart
new file mode 100644
index 0000000..ac3b929
--- /dev/null
+++ b/app/lib/components/spyglass_viewer.dart
@@ -0,0 +1,656 @@
+import 'dart:async';
+import 'dart:convert';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import '../theme.dart';
+import '../features/files/presentation/providers/secure_file_providers.dart';
+import 'secure_viewer/models/watermark_config.dart';
+import 'secure_viewer/models/viewer_config.dart';
+import 'secure_viewer/secure_document_viewer.dart';
+import 'package:pdfrx/pdfrx.dart';
+import 'package:screen_protector/screen_protector.dart';
+import '../features/auth/presentation/providers/auth_providers.dart';
+import '../features/profile/providers/profile_provider.dart';
+import '../features/profile/providers/settings_provider.dart';
+import '../services/screenshot_guard.dart';
+import '../services/audit_service.dart';
+import '../services/device_integrity_service.dart';
+import '../features/auth/presentation/providers/risk_state_provider.dart';
+
+// ─── User identity resolved from live auth providers ────────────────────────
+
+/// The full-screen secure document viewer page.
+///
+/// Loads, renders, and purges volatile document bytes from memory upon exit.
+class SpyglassViewer extends ConsumerStatefulWidget {
+ final String? fileId;
+ final String? groupId;
+ final String? email;
+ final String? phone;
+ final String? documentTitle;
+ final String? documentCategory;
+
+ const SpyglassViewer({
+ super.key,
+ this.fileId,
+ this.groupId,
+ this.email,
+ this.phone,
+ this.documentTitle,
+ this.documentCategory,
+ });
+
+ @override
+ ConsumerState createState() => _SpyglassViewerState();
+}
+
+class _SpyglassViewerState extends ConsumerState {
+ final String _sessionTimestamp = _buildTimestamp();
+ bool _isLoading = true;
+ String _documentContent = '';
+ double _streamProgress = 0.0;
+ String _loadingStatus = 'Requesting secure proxy...';
+ bool _isPdf = false;
+ bool _isImage = false;
+ bool _downloadFailed = false;
+ Uint8List? _documentBytes;
+
+ static String _buildTimestamp() {
+ final now = DateTime.now();
+ return '${now.year}-${_pad(now.month)}-${_pad(now.day)} '
+ '${_pad(now.hour)}:${_pad(now.minute)} IST';
+ }
+
+ static String _pad(int n) => n.toString().padLeft(2, '0');
+
+ @override
+ void initState() {
+ super.initState();
+ SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
+ statusBarColor: Colors.transparent,
+ statusBarBrightness: Brightness.dark,
+ ));
+
+ // Register active document context for screenshot/recording audit logs
+ ScreenshotGuard.instance.activeGroupId = widget.groupId;
+ ScreenshotGuard.instance.activeFileId = widget.fileId;
+ ScreenshotGuard.instance.activeFileName = widget.documentTitle;
+
+ _initScreenshotProtection();
+ _loadSecureEnclavePayload();
+
+ // Display-mirroring / accessibility-abuse check for this viewing
+ // session — logged into this group's tamper-evident audit trail.
+ unawaited(DeviceIntegrityService.instance.runViewingChecks(
+ groupId: widget.groupId,
+ fileId: widget.fileId,
+ ));
+ }
+
+ Future _initScreenshotProtection() async {
+ await ScreenProtector.preventScreenshotOn();
+ await ScreenProtector.protectDataLeakageOn();
+ }
+
+ @override
+ void dispose() {
+ // Clear active document context from screenshot audit logger
+ ScreenshotGuard.instance.activeGroupId = null;
+ ScreenshotGuard.instance.activeFileId = null;
+ ScreenshotGuard.instance.activeFileName = null;
+
+ _documentBytes = null;
+ ScreenProtector.preventScreenshotOff();
+ ScreenProtector.protectDataLeakageOff();
+ super.dispose();
+ }
+
+ Future _loadSecureEnclavePayload() async {
+ Uint8List? plainBytes;
+
+ if (widget.fileId != null) {
+ setState(() {
+ _loadingStatus = 'Connecting to secure repository...';
+ });
+
+ final streamedBytes = await ref.read(secureFileRepositoryProvider).downloadFile(
+ fileId: widget.fileId!,
+ onProgress: (progress) {
+ if (mounted) {
+ setState(() {
+ _streamProgress = progress;
+ _loadingStatus = 'Downloading: ${(progress * 100).toStringAsFixed(0)}%';
+ });
+ }
+ },
+ );
+
+ if (streamedBytes != null) {
+ if (mounted) {
+ setState(() {
+ _loadingStatus = 'Loading document...';
+ });
+ }
+ plainBytes = streamedBytes;
+ }
+ }
+
+ bool downloadFailed = false;
+
+ if (plainBytes == null && widget.fileId != null) {
+ downloadFailed = true;
+ if (widget.groupId != null) {
+ AuditService.instance.logEvent(
+ 'download_failed',
+ 'FAILURE',
+ groupId: widget.groupId!,
+ fileId: widget.fileId,
+ metadata: {'file_name': widget.documentTitle ?? 'document'},
+ );
+ }
+ }
+
+ bool isPdfFile = false;
+ if (plainBytes != null &&
+ plainBytes.length >= 4 &&
+ plainBytes[0] == 0x25 &&
+ plainBytes[1] == 0x50 &&
+ plainBytes[2] == 0x44 &&
+ plainBytes[3] == 0x46) {
+ isPdfFile = true;
+ } else if (plainBytes != null && widget.documentCategory == 'PDF' && !downloadFailed) {
+ isPdfFile = true;
+ }
+
+ // Detect Image by magic bytes or category
+ bool isImageFile = false;
+ if (plainBytes != null &&
+ plainBytes.length >= 3 &&
+ plainBytes[0] == 0xFF &&
+ plainBytes[1] == 0xD8 &&
+ plainBytes[2] == 0xFF) { // JPEG
+ isImageFile = true;
+ } else if (plainBytes != null &&
+ plainBytes.length >= 4 &&
+ plainBytes[0] == 0x89 &&
+ plainBytes[1] == 0x50 &&
+ plainBytes[2] == 0x4E &&
+ plainBytes[3] == 0x47) { // PNG
+ isImageFile = true;
+ } else if (plainBytes != null && (widget.documentCategory == 'IMAGE' || widget.documentCategory == 'SCAN') && !downloadFailed) {
+ isImageFile = true;
+ }
+
+ String decoded = '';
+ if (plainBytes != null && !isPdfFile && !isImageFile) {
+ try {
+ decoded = utf8.decode(plainBytes);
+ } catch (_) {
+ decoded = '[SECURED DOCUMENT PREVIEW]\nThis document is a binary PDF/media asset loaded securely inside the RAM buffer.';
+ }
+ }
+
+ if (mounted) {
+ setState(() {
+ _isPdf = isPdfFile;
+ _isImage = isImageFile;
+ _documentContent = decoded;
+ _documentBytes = plainBytes;
+ _downloadFailed = downloadFailed;
+ _isLoading = false;
+ });
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ final isDark = theme.brightness == Brightness.dark;
+ final bg = isDark ? NoSusTheme.dBackground : NoSusTheme.lBackground;
+ final fg = isDark ? NoSusTheme.dText : NoSusTheme.lText;
+
+ final authState = ref.watch(authStateProvider);
+ final profileAsync = ref.watch(profileProvider);
+ final isWatermarkVisible = ref.watch(watermarkVisibilityProvider);
+
+ final String userEmail = authState.value?.email ?? widget.email ?? 'student@nosus.app';
+ final String displayName = profileAsync.maybeWhen(
+ data: (p) => p.displayName,
+ orElse: () => userEmail.contains('@') ? userEmail.split('@').first : 'Guest Scholar',
+ );
+
+ final watermarkIntensity =
+ ref.watch(riskStateProvider).value?.watermarkIntensity ?? 'normal';
+ final watermarkConfig = WatermarkConfig(
+ name: displayName.toUpperCase(),
+ role: 'SECURE MEMBER',
+ email: userEmail,
+ timestamp: _sessionTimestamp,
+ opacity: isWatermarkVisible ? 0.14 : 0.0,
+ fontSize: 12.0,
+ angleDegrees: -28.0,
+ ).scaledForIntensity(watermarkIntensity);
+
+ const viewerConfig = ViewerConfig(
+ maxBlurSigma: 22.0,
+ revealDuration: Duration(milliseconds: 115),
+ concealDuration: Duration(milliseconds: 210),
+ revealCurve: Curves.easeOut,
+ concealCurve: Curves.easeIn,
+ );
+
+ return Scaffold(
+ backgroundColor: bg,
+ appBar: AppBar(
+ backgroundColor: bg,
+ elevation: 0,
+ scrolledUnderElevation: 0,
+ centerTitle: false,
+ leading: IconButton(
+ icon: Icon(Icons.arrow_back, color: fg, size: 20),
+ onPressed: () => Navigator.of(context).maybePop(),
+ tooltip: 'Back',
+ ),
+ title: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ widget.documentTitle ?? 'Study Notes',
+ style: TextStyle(
+ color: fg,
+ fontSize: 15,
+ fontWeight: FontWeight.w600,
+ letterSpacing: -0.2,
+ ),
+ ),
+ if (widget.documentCategory != null)
+ Text(
+ widget.documentCategory!,
+ style: TextStyle(
+ color: fg.withValues(alpha: 0.45),
+ fontSize: 11,
+ fontWeight: FontWeight.w400,
+ letterSpacing: 0.3,
+ ),
+ ),
+ ],
+ ),
+ actions: [
+ Container(
+ margin: const EdgeInsets.only(right: 16),
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
+ decoration: BoxDecoration(
+ border: Border.all(color: fg.withValues(alpha: 0.18), width: 0.75),
+ borderRadius: BorderRadius.circular(6),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(Icons.lock_outline, size: 11, color: fg.withValues(alpha: 0.5)),
+ const SizedBox(width: 5),
+ Text(
+ 'SECURE',
+ style: TextStyle(
+ color: fg.withValues(alpha: 0.5),
+ fontSize: 10,
+ fontWeight: FontWeight.w600,
+ letterSpacing: 1.5,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ bottom: PreferredSize(
+ preferredSize: const Size.fromHeight(1),
+ child: Container(
+ height: 0.5,
+ color: fg.withValues(alpha: 0.1),
+ ),
+ ),
+ ),
+ body: _isLoading
+ ? Center(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 40.0),
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ const SizedBox(
+ width: 28,
+ height: 28,
+ child: CircularProgressIndicator(strokeWidth: 1.5, color: Colors.grey),
+ ),
+ const SizedBox(height: 24),
+ Text(
+ _loadingStatus.toUpperCase(),
+ style: TextStyle(
+ fontSize: 10,
+ fontWeight: FontWeight.w700,
+ letterSpacing: 2.0,
+ color: fg.withValues(alpha: 0.5),
+ ),
+ ),
+ if (widget.fileId != null) ...[
+ const SizedBox(height: 12),
+ ClipRRect(
+ borderRadius: BorderRadius.circular(4),
+ child: LinearProgressIndicator(
+ value: _streamProgress,
+ backgroundColor: fg.withValues(alpha: 0.1),
+ valueColor: AlwaysStoppedAnimation(fg.withValues(alpha: 0.6)),
+ minHeight: 2,
+ ),
+ ),
+ ],
+ ],
+ ),
+ ),
+ )
+ : _downloadFailed
+ ? _buildDownloadFailedBody(fg)
+ : _buildDocumentBody(watermarkConfig, viewerConfig, isDark, fg, bg),
+ );
+ }
+
+ Widget _buildDownloadFailedBody(Color fg) {
+ return Center(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 32.0),
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(
+ Icons.cloud_off_outlined,
+ size: 40,
+ color: fg.withValues(alpha: 0.4),
+ ),
+ const SizedBox(height: 20),
+ Text(
+ 'DOWNLOAD FAILED',
+ style: TextStyle(
+ fontSize: 12,
+ fontWeight: FontWeight.w800,
+ letterSpacing: 2.0,
+ color: fg,
+ ),
+ ),
+ const SizedBox(height: 12),
+ Text(
+ 'The document could not be loaded.\n\nPlease verify your connection and ensure you are a registered enclave member of this study group.',
+ textAlign: TextAlign.center,
+ style: TextStyle(
+ fontSize: 13,
+ color: fg.withValues(alpha: 0.55),
+ height: 1.5,
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _buildDocumentBody(
+ WatermarkConfig watermarkConfig,
+ ViewerConfig viewerConfig,
+ bool isDark,
+ Color fg,
+ Color bg,
+ ) {
+ final buffer = _documentBytes;
+
+ // Null guard — if buffer was purged (e.g. widget re-entry) show a safe error state.
+ if (buffer == null && (_isPdf || _isImage)) {
+ return Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(Icons.lock_reset_outlined, size: 36, color: fg.withValues(alpha: 0.3)),
+ const SizedBox(height: 16),
+ Text(
+ 'SESSION EXPIRED',
+ style: TextStyle(
+ fontSize: 11,
+ fontWeight: FontWeight.w700,
+ letterSpacing: 2.0,
+ color: fg.withValues(alpha: 0.4),
+ ),
+ ),
+ const SizedBox(height: 8),
+ Text(
+ 'Document buffer was purged.\nReturn and open the document again.',
+ textAlign: TextAlign.center,
+ style: TextStyle(fontSize: 13, color: fg.withValues(alpha: 0.5)),
+ ),
+ ],
+ ),
+ );
+ }
+
+ return SecureDocumentViewer(
+ watermarkConfig: watermarkConfig,
+ viewerConfig: viewerConfig,
+ showHint: true,
+ touchToRevealEnabled: ref.watch(touchToRevealProvider),
+ child: _isPdf
+ ? PdfViewer.data(
+ buffer!,
+ sourceName: widget.documentTitle ?? 'document.pdf',
+ // Text selection off: it's a copy-out leak in a protected
+ // viewer, and pdfrx 2.4.4's selection painter crashes on
+ // pages with no extractable text (StateError: No element).
+ params: const PdfViewerParams(
+ textSelectionParams: PdfTextSelectionParams(enabled: false),
+ ),
+ )
+ : _isImage
+ ? InteractiveViewer(
+ child: Center(
+ child: Image.memory(buffer!),
+ ),
+ )
+ : _DocumentContent(
+ isDark: isDark,
+ fg: fg,
+ bg: bg,
+ text: _documentContent,
+ ),
+ );
+ }
+}
+
+// ─── Demo Document Content ────────────────────────────────────────────────────
+
+class _DocumentContent extends StatelessWidget {
+ final bool isDark;
+ final Color fg;
+ final Color bg;
+ final String text;
+
+ const _DocumentContent({
+ required this.isDark,
+ required this.fg,
+ required this.bg,
+ required this.text,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final dividerColor = fg.withValues(alpha: 0.08);
+ final subtleColor = fg.withValues(alpha: 0.45);
+ final cardBg = isDark ? const Color(0xFF1A1A1A) : Colors.white;
+
+ final paragraphs = text.split('\n\n');
+
+ return SingleChildScrollView(
+ physics: NoSusTheme.getScrollPhysics(context),
+ padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ _DocHeader(
+ subject: 'DOCUMENT VIEWER',
+ chapter: paragraphs.isNotEmpty ? paragraphs.first.split('\n').first : 'Document Content',
+ date: 'SECURE VIEWER',
+ fg: fg,
+ subtleColor: subtleColor,
+ ),
+ const SizedBox(height: 28),
+ Divider(color: dividerColor, thickness: 0.5),
+ const SizedBox(height: 28),
+ for (final p in paragraphs) ...[
+ if (p.trim().isNotEmpty) ...[
+ _renderParagraph(p, cardBg, dividerColor),
+ const SizedBox(height: 24),
+ ],
+ ],
+ const SizedBox(height: 40),
+ Center(
+ child: Text(
+ '— DOCUMENT CLEARED ON EXIT —',
+ style: TextStyle(
+ color: subtleColor,
+ fontSize: 10,
+ letterSpacing: 2.0,
+ fontWeight: FontWeight.w500,
+ ),
+ ),
+ ),
+ const SizedBox(height: 40),
+ ],
+ ),
+ );
+ }
+
+ Widget _renderParagraph(String p, Color cardBg, Color dividerColor) {
+ final lines = p.split('\n');
+ final title = lines.first;
+ final body = lines.skip(1).join('\n');
+
+ if (body.isEmpty) {
+ return Text(
+ title,
+ style: TextStyle(
+ color: fg.withValues(alpha: 0.8),
+ fontSize: 14,
+ fontWeight: FontWeight.w400,
+ height: 1.75,
+ ),
+ );
+ }
+
+ return _Section(
+ title: title,
+ body: body,
+ fg: fg,
+ subtleColor: fg.withValues(alpha: 0.45),
+ cardBg: cardBg,
+ dividerColor: dividerColor,
+ );
+ }
+}
+
+// ─── Document sub-widgets ─────────────────────────────────────────────────────
+
+class _DocHeader extends StatelessWidget {
+ final String subject;
+ final String chapter;
+ final String date;
+ final Color fg;
+ final Color subtleColor;
+
+ const _DocHeader({
+ required this.subject,
+ required this.chapter,
+ required this.date,
+ required this.fg,
+ required this.subtleColor,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ subject.toUpperCase(),
+ style: TextStyle(
+ color: subtleColor,
+ fontSize: 11,
+ fontWeight: FontWeight.w500,
+ letterSpacing: 2.0,
+ ),
+ ),
+ const SizedBox(height: 8),
+ Text(
+ chapter,
+ style: TextStyle(
+ color: fg,
+ fontSize: 22,
+ fontWeight: FontWeight.w700,
+ letterSpacing: -0.5,
+ height: 1.2,
+ ),
+ ),
+ const SizedBox(height: 8),
+ Text(
+ date,
+ style: TextStyle(
+ color: subtleColor,
+ fontSize: 12,
+ fontWeight: FontWeight.w400,
+ ),
+ ),
+ ],
+ );
+ }
+}
+
+class _Section extends StatelessWidget {
+ final String title;
+ final String body;
+ final Color fg;
+ final Color subtleColor;
+ final Color cardBg;
+ final Color dividerColor;
+
+ const _Section({
+ required this.title,
+ required this.body,
+ required this.fg,
+ required this.subtleColor,
+ required this.cardBg,
+ required this.dividerColor,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ title,
+ style: TextStyle(
+ color: fg,
+ fontSize: 16,
+ fontWeight: FontWeight.w600,
+ letterSpacing: -0.3,
+ ),
+ ),
+ const SizedBox(height: 12),
+ Text(
+ body,
+ style: TextStyle(
+ color: fg.withValues(alpha: 0.75),
+ fontSize: 14,
+ fontWeight: FontWeight.w400,
+ height: 1.75,
+ letterSpacing: 0.1,
+ ),
+ ),
+ ],
+ );
+ }
+}
diff --git a/app/lib/components/study_chart.dart b/app/lib/components/study_chart.dart
new file mode 100644
index 0000000..407a58b
--- /dev/null
+++ b/app/lib/components/study_chart.dart
@@ -0,0 +1,242 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import '../theme.dart';
+import '../features/focus/providers/focus_provider.dart';
+
+class StudyChart extends ConsumerStatefulWidget {
+ const StudyChart({super.key});
+
+ @override
+ ConsumerState createState() => _StudyChartState();
+}
+
+class _StudyChartState extends ConsumerState {
+ int _selectedDayIndex = 6; // Default to Friday/Today (last element)
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ final isDark = theme.brightness == Brightness.dark;
+ final timelineAsync = ref.watch(studyTimelineProvider);
+
+ return timelineAsync.when(
+ data: (data) {
+ if (data.isEmpty) {
+ return const Center(child: Text("No focus data"));
+ }
+
+ // Find max hours to scale bars
+ double maxHours = data
+ .map((d) => d.hours)
+ .fold(1.0, (max, val) => val > max ? val : max);
+ if (maxHours < 1.0) maxHours = 1.0;
+
+ // Ensure selected index is within bounds
+ if (_selectedDayIndex >= data.length) {
+ _selectedDayIndex = data.length - 1;
+ }
+
+ final selectedData = data[_selectedDayIndex];
+
+ return Container(
+ padding: const EdgeInsets.all(NoSusTheme.s24),
+ decoration: NoSusTheme.cardDecoration(context),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ 'FOCUS TIMELINE',
+ style: theme.textTheme.labelLarge?.copyWith(
+ color: theme.colorScheme.onSurface.withValues(
+ alpha: 0.5,
+ ),
+ ),
+ ),
+ const SizedBox(height: NoSusTheme.s4),
+ Text(
+ '${selectedData.hours}h Focused',
+ style: theme.textTheme.displayMedium?.copyWith(
+ fontSize: 24,
+ ),
+ ),
+ ],
+ ),
+ Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: NoSusTheme.s12,
+ vertical: NoSusTheme.s8,
+ ),
+ decoration: NoSusTheme.buttonDecoration(
+ context,
+ radius: 10,
+ ),
+ child: Row(
+ children: [
+ Icon(
+ Icons.shield_outlined,
+ size: 14,
+ color: theme.colorScheme.onSurface,
+ ),
+ const SizedBox(width: NoSusTheme.s4),
+ Text(
+ '${selectedData.scans} Checks',
+ style: theme.textTheme.bodyMedium?.copyWith(
+ color: theme.colorScheme.onSurface,
+ fontWeight: FontWeight.bold,
+ fontSize: 12,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: NoSusTheme.s32),
+ // Chart Graphic
+ Expanded(
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.end,
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: List.generate(data.length, (index) {
+ final dayData = data[index];
+ final isSelected = index == _selectedDayIndex;
+ final percentage = dayData.hours / maxHours;
+
+ return Expanded(
+ child: Semantics(
+ button: true,
+ selected: isSelected,
+ label:
+ '${dayData.day}: ${dayData.hours.toStringAsFixed(1)} hours',
+ child: GestureDetector(
+ onTap: () {
+ setState(() {
+ _selectedDayIndex = index;
+ });
+ },
+ child: MouseRegion(
+ cursor: SystemMouseCursors.click,
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.end,
+ children: [
+ Expanded(
+ child: LayoutBuilder(
+ builder: (context, constraints) {
+ final barHeight =
+ constraints.maxHeight * percentage;
+ return Stack(
+ alignment: Alignment.bottomCenter,
+ children: [
+ // Base track guide line
+ Positioned(
+ top: 0,
+ bottom: 0,
+ child: Container(
+ width: 1,
+ color: theme.colorScheme.outline
+ .withValues(alpha: 0.1),
+ ),
+ ),
+ // Animated Bar
+ AnimatedContainer(
+ duration: const Duration(
+ milliseconds: 300,
+ ),
+ curve: Curves.fastOutSlowIn,
+ width: 28,
+ height: barHeight.clamp(
+ 4.0,
+ double.infinity,
+ ),
+ decoration: BoxDecoration(
+ color: isSelected
+ ? theme.colorScheme.onSurface
+ : (isDark
+ ? Colors.white
+ .withValues(
+ alpha: 0.08,
+ )
+ : Colors.black
+ .withValues(
+ alpha: 0.04,
+ )),
+ borderRadius:
+ const BorderRadius.vertical(
+ top: Radius.circular(6),
+ ),
+ border: Border.all(
+ color: isSelected
+ ? theme
+ .colorScheme
+ .onSurface
+ : theme.colorScheme.outline,
+ width: 1.2,
+ ),
+ ),
+ ),
+ ],
+ );
+ },
+ ),
+ ),
+ const SizedBox(height: NoSusTheme.s12),
+ Text(
+ dayData.day,
+ style: theme.textTheme.labelLarge?.copyWith(
+ fontSize: 11,
+ fontWeight: isSelected
+ ? FontWeight.bold
+ : FontWeight.normal,
+ color: isSelected
+ ? theme.colorScheme.onSurface
+ : theme.colorScheme.onSurface
+ .withValues(alpha: 0.4),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+ }),
+ ),
+ ),
+ ],
+ ),
+ );
+ },
+ loading: () => Container(
+ padding: const EdgeInsets.all(NoSusTheme.s24),
+ decoration: NoSusTheme.cardDecoration(context),
+ child: const Center(
+ child: CircularProgressIndicator(color: Colors.grey),
+ ),
+ ),
+ error: (err, stack) => Container(
+ padding: const EdgeInsets.all(NoSusTheme.s24),
+ decoration: NoSusTheme.cardDecoration(context),
+ child: Center(
+ child: Text(
+ 'Error loading timeline: $err',
+ style: const TextStyle(color: Colors.redAccent),
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class StudyDayData {
+ final String day;
+ final double hours;
+ final int scans;
+
+ StudyDayData({required this.day, required this.hours, required this.scans});
+}
diff --git a/app/lib/components/update_banner.dart b/app/lib/components/update_banner.dart
new file mode 100644
index 0000000..cd045f0
--- /dev/null
+++ b/app/lib/components/update_banner.dart
@@ -0,0 +1,99 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:url_launcher/url_launcher.dart';
+
+import '../features/config/domain/app_update.dart';
+import '../features/config/presentation/providers/app_update_provider.dart';
+import '../theme.dart';
+
+/// Dismissible strip shown when a newer app version is available — same
+/// visual language and per-session dismissal contract as [OfflineBanner].
+/// Renders nothing on web/non-Android or when the app is current (see
+/// appUpdateProvider for the platform gating).
+class UpdateBanner extends ConsumerStatefulWidget {
+ const UpdateBanner({super.key});
+
+ @override
+ ConsumerState createState() => _UpdateBannerState();
+}
+
+class _UpdateBannerState extends ConsumerState {
+ bool _dismissed = false;
+
+ Future _openDownload(String url) async {
+ final uri = Uri.parse(url);
+ if (await canLaunchUrl(uri)) {
+ await launchUrl(uri, mode: LaunchMode.externalApplication);
+ } else {
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('Could not open download link')),
+ );
+ }
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ if (_dismissed) return const SizedBox.shrink();
+ final status = ref.watch(appUpdateProvider).value;
+ if (status is! AppUpdateAvailable) return const SizedBox.shrink();
+
+ final theme = Theme.of(context);
+ final isDark = theme.brightness == Brightness.dark;
+
+ return Container(
+ width: double.infinity,
+ padding: const EdgeInsets.symmetric(
+ horizontal: NoSusTheme.s16,
+ vertical: NoSusTheme.s12,
+ ),
+ decoration: BoxDecoration(
+ color: isDark ? NoSusTheme.dCard : NoSusTheme.lCard,
+ border: Border(
+ bottom: BorderSide(color: theme.colorScheme.outline, width: 1.0),
+ ),
+ ),
+ child: Row(
+ children: [
+ Icon(
+ Icons.system_update_alt_outlined,
+ size: 16,
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
+ ),
+ const SizedBox(width: NoSusTheme.s8),
+ Expanded(
+ child: Text(
+ 'Update available: v${status.latestVersion}',
+ style: theme.textTheme.bodyMedium?.copyWith(fontSize: 12),
+ ),
+ ),
+ TextButton(
+ onPressed: () => _openDownload(status.downloadUrl),
+ style: TextButton.styleFrom(
+ padding: const EdgeInsets.symmetric(horizontal: NoSusTheme.s8),
+ minimumSize: const Size(0, 44),
+ ),
+ child: Text(
+ 'UPDATE',
+ style: theme.textTheme.bodyMedium?.copyWith(
+ fontSize: 11,
+ fontWeight: FontWeight.w700,
+ letterSpacing: 1.0,
+ ),
+ ),
+ ),
+ IconButton(
+ onPressed: () => setState(() => _dismissed = true),
+ tooltip: 'Dismiss update notice',
+ icon: Icon(
+ Icons.close,
+ size: 16,
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.4),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/app/lib/config/app_integrity_config.dart b/app/lib/config/app_integrity_config.dart
new file mode 100644
index 0000000..905b93c
--- /dev/null
+++ b/app/lib/config/app_integrity_config.dart
@@ -0,0 +1,27 @@
+/// Play Integrity API feature flag.
+///
+/// Off by default. Even once [enabled] is flipped on via
+/// `--dart-define=APP_INTEGRITY_ENABLED=true`, requesting a real token still
+/// requires:
+/// 1. A Google Cloud project linked to this app in Play Console (App
+/// integrity → Play Integrity API), with the Play Integrity API enabled.
+/// 2. That project's number filled into `cloudProjectNumber` in
+/// android/app/src/main/kotlin/foo/nosus/app/security/PlayIntegrityManager.kt
+/// (currently 0, which fails fast rather than silently misbehaving).
+/// 3. The app installed via Play (or Play's internal testing track) —
+/// Play Integrity tokens can't be minted for a sideloaded debug build.
+/// 4. supabase/functions/verify-play-integrity deployed and able to call
+/// Google's decodeIntegrityToken endpoint with a service account that
+/// has Play Integrity API access (GOOGLE_PLAY_SERVICE_ACCOUNT_JSON may
+/// or may not already have this scope — verify before relying on it).
+///
+/// None of this has been exercised against a real device/token in this
+/// environment — the plumbing is written and internally consistent, not
+/// verified end-to-end. Treat as scaffolding until someone completes the
+/// four steps above and tests on a real device.
+class AppIntegrityConfig {
+ AppIntegrityConfig._();
+
+ static const bool enabled =
+ bool.fromEnvironment('APP_INTEGRITY_ENABLED', defaultValue: false);
+}
diff --git a/app/lib/config/app_mode.dart b/app/lib/config/app_mode.dart
new file mode 100644
index 0000000..23551cf
--- /dev/null
+++ b/app/lib/config/app_mode.dart
@@ -0,0 +1,26 @@
+/// True only for the standalone NO SUS Canary web build
+/// (flutter build web ... --dart-define=NOSUS_CANARY_ONLY=true).
+/// The full NO SUS app (APK, main web app) never sets it.
+const bool kCanaryOnly = bool.fromEnvironment('NOSUS_CANARY_ONLY');
+
+/// Shows the Monad testnet prototype in NO SUS Canary: the wallet button,
+/// Monad proof chips, "Verify on Monad" and all Monad wording. Off by
+/// default: Solana devnet is the only receipt the product shows. Build with
+/// --dart-define=CANARY_MONAD=true to reproduce the earlier Monad build.
+const bool kCanaryMonad = bool.fromEnvironment(
+ 'CANARY_MONAD',
+ defaultValue: false,
+);
+
+/// Optional deployed contract address to show on the Canary landing page
+/// (Monad testnet; only shown when [kCanaryMonad] is true).
+const String kCanaryContract = String.fromEnvironment(
+ 'CANARY_CONTRACT',
+ defaultValue: '0xb1a1858866122c84cf97861ca815fb070e010e68',
+);
+
+/// Optional repo URL for the open-source link in the hero/footer.
+const String kCanaryRepoUrl = String.fromEnvironment(
+ 'CANARY_REPO_URL',
+ defaultValue: 'https://github.com/https-shubhamsahu/nosus-canary',
+);
diff --git a/app/lib/config/crash_reporting_config.dart b/app/lib/config/crash_reporting_config.dart
new file mode 100644
index 0000000..8506368
--- /dev/null
+++ b/app/lib/config/crash_reporting_config.dart
@@ -0,0 +1,34 @@
+/// Crash reporting configuration (Sentry).
+///
+/// Off by default — [sentryDsn] is empty until explicitly provided via
+/// `--dart-define=SENTRY_DSN=...` (or a `.env` picked up by
+/// `--dart-define-from-file`) and is disabled unless explicitly configured.
+/// To turn this on:
+/// 1. Create a free project at sentry.io (Flutter platform).
+/// 2. Copy its DSN.
+/// 3. Add `SENTRY_DSN=https://...` to your `.env` / CI secret and pass it
+/// through `--dart-define-from-file`.
+/// 4. Update `store_listing/data_safety_answers.md` — enabling this adds a
+/// new third-party data flow (crash/error metadata to Sentry) that the
+/// Play Console Data Safety form does not currently disclose.
+///
+/// This product's whole premise is zero-knowledge/no-tracking (see
+/// PROJECT_CONSTITUTION.md), so the wiring in main.dart deliberately sets
+/// `sendDefaultPii = false` and keeps tracing off by default — this is
+/// crash/error visibility, not analytics.
+class CrashReportingConfig {
+ CrashReportingConfig._();
+
+ static const String sentryDsn =
+ String.fromEnvironment('SENTRY_DSN', defaultValue: '');
+
+ static bool get isEnabled => sentryDsn.isNotEmpty;
+
+ /// Performance tracing is separate from error capture and stays off even
+ /// when error capture is enabled — it's a distinct data flow (full
+ /// request/navigation traces) this product doesn't opt into silently.
+ /// (No dart-define for this one — there's no `double.fromEnvironment` in
+ /// Dart, and tracing isn't something to flip on casually for this
+ /// product anyway; change this constant directly if it's ever needed.)
+ static const double tracesSampleRate = 0.0;
+}
diff --git a/app/lib/config/intelligence_config.dart b/app/lib/config/intelligence_config.dart
new file mode 100644
index 0000000..75cf3ff
--- /dev/null
+++ b/app/lib/config/intelligence_config.dart
@@ -0,0 +1,26 @@
+/// Optional document-intelligence layer. Completely independent of sharing,
+/// auth, and viewing.
+///
+/// The core product works with every flag false. Providers are selected at
+/// compile time so a Gemini competition build does not ship a local model,
+/// and a local-AI build does not need a cloud key.
+///
+/// --dart-define=INTELLIGENCE_PROVIDER=gemini
+/// --dart-define=INTELLIGENCE_PROVIDER=local
+///
+/// Credentials never live in the client. Gemini goes through a server
+/// function that holds `GEMINI_API_KEY`. Local runs on-device with no
+/// network.
+class IntelligenceConfig {
+ IntelligenceConfig._();
+
+ static const String provider = String.fromEnvironment(
+ 'INTELLIGENCE_PROVIDER',
+ defaultValue: 'disabled',
+ );
+
+ static bool get isDisabled => provider == 'disabled' || provider.isEmpty;
+ static bool get isEnabled => !isDisabled;
+ static bool get useGemini => provider == 'gemini';
+ static bool get useLocal => provider == 'local';
+}
diff --git a/app/lib/config/measure_reporting_config.dart b/app/lib/config/measure_reporting_config.dart
new file mode 100644
index 0000000..9c3d89e
--- /dev/null
+++ b/app/lib/config/measure_reporting_config.dart
@@ -0,0 +1,75 @@
+import 'package:flutter/foundation.dart';
+
+/// Measure (measure.sh) monitoring configuration.
+///
+/// Off by default, exactly like [CrashReportingConfig] — both values are empty
+/// until supplied via `--dart-define=MEASURE_API_KEY=...` /
+/// `--dart-define=MEASURE_API_URL=...` (or a `.env` picked up by
+/// `--dart-define-from-file`, which is how CI does it). With either one blank
+/// the SDK is never initialised and never started, so an unconfigured build
+/// carries the dependency but performs no collection and makes no network
+/// calls.
+///
+/// **Android only.** `measure_flutter` registers plugin implementations for
+/// `android` and `ios` only. It is conditionally imported by
+/// `services/measure_reporting.dart`, so its JavaScript-incompatible package
+/// code is excluded from the web program rather than merely skipped at runtime.
+/// iOS is excluded too: that target has never shipped (still on Flutter's
+/// `com.example.*` template ids, see AGENTS.md §5) and Measure's iOS setup
+/// needs a `pod install` + `AppDelegate` change that does not exist here.
+///
+/// **The same two values are also read by Gradle**, out of the repo-root
+/// `.env`, and injected into `AndroidManifest.xml` as the
+/// `sh.measure.android.API_KEY` / `API_URL` meta-data the native SDK reads —
+/// see `android/app/build.gradle.kts`. That is not duplication for its own
+/// sake: the native SDK initialises in `Application.onCreate`, long before Dart
+/// exists, so it cannot be told anything by a `--dart-define`. Keeping both
+/// sides fed from one `.env` is what stops the Dart gate and the native gate
+/// from disagreeing. If you set one without the other, the half that is
+/// configured runs and the half that is not stays dark.
+///
+/// ## Before enabling this, know what it collects
+///
+/// Measure is session monitoring, not just crash capture, and two of its
+/// behaviours are actively dangerous for *this* product:
+///
+/// 1. **Screenshot-on-crash cannot be turned off from app code.** It lives in
+/// the SDK's server-driven `DynamicConfig` (`crash_take_screenshot`,
+/// defaults to `true`), fetched from the Measure backend — neither the Dart
+/// nor the Android `MeasureConfig` exposes it. It must be disabled in the
+/// Measure dashboard. Until it is, a crash inside `BurnNoteViewerScreen`,
+/// `BurnFileViewerScreen` or `SpyglassViewer` can upload decrypted content.
+/// `MainActivity` sets `FLAG_SECURE` window-wide, which blanks the `PixelCopy`
+/// capture path the SDK uses on API 26+, but it falls back to the Canvas API
+/// on older devices and `FLAG_SECURE` does not block that.
+/// 2. **Gesture tracking records widget labels.** `ClickData` carries `label`
+/// and `semanticLabel`, which in this app are note titles, group names and
+/// filenames. This is why `main.dart` deliberately does **not** wrap the app
+/// in `MeasureWidget` and does not install `MsrNavigatorObserver` — without
+/// that wrapper the SDK captures crashes and app-health metrics but no
+/// interaction timeline. Adding either re-opens this; don't, without
+/// re-reading `store_listing/data_safety_answers.md`.
+///
+/// Turning this on is a Play Data Safety change (a new third-party data flow),
+/// same as `SENTRY_DSN` — see `store_listing/data_safety_answers.md`.
+class MeasureReportingConfig {
+ MeasureReportingConfig._();
+
+ static const String apiKey =
+ String.fromEnvironment('MEASURE_API_KEY', defaultValue: '');
+
+ static const String apiUrl =
+ String.fromEnvironment('MEASURE_API_URL', defaultValue: '');
+
+ /// Both halves of the credential must be present — a key without a URL (or
+ /// vice versa) is a misconfiguration, not a partial enablement.
+ static bool get isConfigured => apiKey.isNotEmpty && apiUrl.isNotEmpty;
+
+ /// `kIsWeb` is checked *first* and separately: [defaultTargetPlatform] reports
+ /// `TargetPlatform.android` for Chrome on an Android phone, so a web-only
+ /// check via the platform enum alone would let the web build through.
+ static bool get isSupportedPlatform =>
+ !kIsWeb && defaultTargetPlatform == TargetPlatform.android;
+
+ static bool get isEnabled => isConfigured && isSupportedPlatform;
+}
diff --git a/app/lib/config/storage_router_config.dart b/app/lib/config/storage_router_config.dart
new file mode 100644
index 0000000..dad109d
--- /dev/null
+++ b/app/lib/config/storage_router_config.dart
@@ -0,0 +1,15 @@
+/// Feature flags and endpoint names for the optional multi-cloud storage router.
+///
+/// Existing Supabase Storage remains the default. When STORAGE_MULTICLOUD=true,
+/// uploads/downloads/deletes are routed through the `storage-router` Edge
+/// Function, which owns S3-compatible provider credentials and provider choice.
+class StorageRouterConfig {
+ StorageRouterConfig._();
+
+ static const bool enableMultiCloudStorage = bool.fromEnvironment(
+ 'STORAGE_MULTICLOUD',
+ defaultValue: false,
+ );
+
+ static const String edgeFunctionName = 'storage-router';
+}
diff --git a/app/lib/config/supabase_credentials.dart b/app/lib/config/supabase_credentials.dart
new file mode 100644
index 0000000..30666dc
--- /dev/null
+++ b/app/lib/config/supabase_credentials.dart
@@ -0,0 +1,12 @@
+/// Configuration file hosting the credentials for the Supabase backend client.
+///
+/// Leave both [url] and [anonKey] empty to run the application in mock fallback mode.
+///
+/// To configure, create a `.env` file in the project root or set environment variables:
+/// SUPABASE_URL=https://your-project.supabase.co
+/// SUPABASE_ANON_KEY=your-anon-key
+class SupabaseCredentials {
+ static const String url = 'https://ffidvfguojalzpclipzi.supabase.co';
+
+ static const String anonKey = 'sb_publishable_D_mmlb1jjS9Cl6QCuFwEJw_zPAb6kHS';
+}
diff --git a/app/lib/core/auth/session_recovery.dart b/app/lib/core/auth/session_recovery.dart
new file mode 100644
index 0000000..12ad6b9
--- /dev/null
+++ b/app/lib/core/auth/session_recovery.dart
@@ -0,0 +1,47 @@
+import 'package:supabase_flutter/supabase_flutter.dart';
+
+/// Distinguishes a dead session from a transient failure.
+///
+/// A cached JWT whose *access* token has expired is normal — GoTrue will
+/// refresh it. Signing the user out because `getUser(expiredAccessToken)`
+/// threw is what made reopening the app feel like a logout.
+class SessionRecovery {
+ const SessionRecovery._();
+
+ /// True only for errors that mean the refresh token itself is unusable
+ /// (revoked, user deleted, corrupted storage). Network blips, 5xx, and
+ /// "JWT expired" on the access token are not permanent.
+ static bool isPermanentAuthFailure(Object error) {
+ if (error is! AuthException) return false;
+ final code = (error.code ?? '').toLowerCase();
+ final message = error.message.toLowerCase();
+ if (code == 'refresh_token_not_found' ||
+ code == 'user_not_found' ||
+ code == 'session_not_found' ||
+ code == 'invalid_refresh_token') {
+ return true;
+ }
+ return message.contains('refresh_token_not_found') ||
+ message.contains('invalid refresh token') ||
+ message.contains('user not found') ||
+ (message.contains('refresh') && message.contains('not found'));
+ }
+
+ /// If a session is already expired, ask GoTrue to refresh it.
+ ///
+ /// Never signs out on network errors. Only clears local state when the
+ /// server says the refresh token is dead — otherwise the next launch
+ /// (or the next successful refresh) still has a valid session.
+ static Future recoverIfNeeded(GoTrueClient auth) async {
+ final session = auth.currentSession;
+ if (session == null) return;
+ if (!session.isExpired) return;
+ try {
+ await auth.refreshSession();
+ } catch (error) {
+ if (isPermanentAuthFailure(error)) {
+ await auth.signOut();
+ }
+ }
+ }
+}
diff --git a/app/lib/core/cache/document_cache.dart b/app/lib/core/cache/document_cache.dart
new file mode 100644
index 0000000..86cb3e8
--- /dev/null
+++ b/app/lib/core/cache/document_cache.dart
@@ -0,0 +1,54 @@
+import 'dart:typed_data';
+
+/// Session-scoped LRU for downloaded document bytes.
+///
+/// Re-opening a document currently re-downloads it every time. This keeps the
+/// most recently viewed files in memory so a re-open is instant. Deliberately
+/// memory-only: nothing secure ever touches disk, it vanishes with the
+/// process, and there's no I/O that could jank the UI. Bounded by both total
+/// bytes and entry count so it can never balloon.
+class DocumentCache {
+ DocumentCache._();
+ static final DocumentCache instance = DocumentCache._();
+
+ static const int _maxTotalBytes = 40 * 1024 * 1024; // 40 MB
+ static const int _maxEntries = 12;
+
+ // LinkedHashMap iteration order = insertion order; re-inserting on access
+ // makes the first key the least recently used.
+ final _entries = {};
+ int _totalBytes = 0;
+
+ Uint8List? get(String fileId) {
+ final bytes = _entries.remove(fileId);
+ if (bytes == null) return null;
+ _entries[fileId] = bytes; // refresh recency
+ return bytes;
+ }
+
+ void put(String fileId, Uint8List bytes) {
+ // A single file larger than the whole budget just isn't cached.
+ if (bytes.length > _maxTotalBytes) return;
+ final existing = _entries.remove(fileId);
+ if (existing != null) _totalBytes -= existing.length;
+
+ _entries[fileId] = bytes;
+ _totalBytes += bytes.length;
+
+ while (_totalBytes > _maxTotalBytes || _entries.length > _maxEntries) {
+ final oldestKey = _entries.keys.first;
+ _totalBytes -= _entries.remove(oldestKey)!.length;
+ }
+ }
+
+ /// Called when a file is deleted or replaced so stale bytes can't be shown.
+ void evict(String fileId) {
+ final removed = _entries.remove(fileId);
+ if (removed != null) _totalBytes -= removed.length;
+ }
+
+ void clear() {
+ _entries.clear();
+ _totalBytes = 0;
+ }
+}
diff --git a/app/lib/core/constants/app_constants.dart b/app/lib/core/constants/app_constants.dart
new file mode 100644
index 0000000..d506b2f
--- /dev/null
+++ b/app/lib/core/constants/app_constants.dart
@@ -0,0 +1,110 @@
+/// Central constants registry for NO SUS.
+///
+/// Use this file instead of scattering magic strings across the codebase.
+/// Prevents duplicate literal values and makes global changes a single edit.
+library;
+
+import 'package:flutter/material.dart';
+
+class AppConstants {
+ const AppConstants._();
+
+ // ─── App Identity ──────────────────────────────────────────────────────────
+ static const String appName = 'NO SUS';
+ static const String appTagline = 'See who opened it';
+ static const String appVersion = '1.4.0';
+
+ // ─── Avatar Colors → Asset Paths ──────────────────────────────────────────
+ static const String defaultAvatarColorStart = 'FF0072FF';
+ static const String defaultAvatarColorEnd = 'FF00F2FE';
+
+ static const Map avatarColorToAsset = {
+ 'FF0072FF': 'assets/images/avatar_builder.png',
+ 'FFCCCCCC': 'assets/images/avatar_researcher.png',
+ 'FFFF0072': 'assets/images/avatar_creator.png',
+ 'FFF5A623': 'assets/images/avatar_academic.png',
+ 'FF800080': 'assets/images/avatar_chaos.png',
+ 'FFADF474': 'assets/images/avatar_archivist.png',
+ };
+
+ /// Returns the avatar asset path for a given [colorStart] hex string.
+ static String avatarAsset(String colorStart) =>
+ avatarColorToAsset[colorStart] ?? avatarColorToAsset[defaultAvatarColorStart]!;
+
+ // ─── Avatar Roles ─────────────────────────────────────────────────────────
+ static const Map avatarColorToRole = {
+ 'FF0072FF': 'SYSTEMS ARCHITECT',
+ 'FFCCCCCC': 'RESEARCH SCHOLAR',
+ 'FFFF0072': 'SECURITY SPECIALIST',
+ 'FFF5A623': 'COMPLIANCE AUDITOR',
+ 'FF800080': 'THREAT INTELLIGENCE',
+ 'FFADF474': 'INFORMATION TRUSTEE',
+ };
+
+ static const String defaultRole = 'SYSTEMS ARCHITECT';
+
+ /// Returns the persona role label for a given [colorStart] hex string.
+ static String avatarRole(String colorStart) =>
+ avatarColorToRole[colorStart] ?? defaultRole;
+
+ /// Returns the professional icon for a given [colorStart] hex string.
+ static IconData avatarIcon(String colorStart) {
+ switch (colorStart) {
+ case 'FF0072FF':
+ return Icons.dns_outlined;
+ case 'FFCCCCCC':
+ return Icons.science_outlined;
+ case 'FFFF0072':
+ return Icons.security_outlined;
+ case 'FFF5A623':
+ return Icons.gavel_outlined;
+ case 'FF800080':
+ return Icons.psychology_outlined;
+ case 'FFADF474':
+ return Icons.folder_shared_outlined;
+ default:
+ return Icons.person_outline;
+ }
+ }
+
+ /// Returns the pixel avatar asset path for a given [avatarId].
+ static String avatarAssetForId(String avatarId) {
+ switch (avatarId) {
+ case 'avatar_01':
+ return 'assets/images/avatar_builder.png';
+ case 'avatar_02':
+ return 'assets/images/avatar_researcher.png';
+ case 'avatar_03':
+ return 'assets/images/avatar_creator.png';
+ case 'avatar_04':
+ return 'assets/images/avatar_academic.png';
+ case 'avatar_05':
+ return 'assets/images/avatar_chaos.png';
+ case 'avatar_06':
+ return 'assets/images/avatar_archivist.png';
+ default:
+ return 'assets/images/avatar_builder.png';
+ }
+ }
+
+ // ─── Default Note Content ─────────────────────────────────────────────────
+ static const String defaultNoteContent =
+ "Welcome to NO SUS.\n\n"
+ "Send a sensitive document and still see who opened it.\n\n"
+ "1. Share a file or a one-time Burn drop from Home.\n"
+ "2. Send the link. Tell them the 2-digit code.\n"
+ "3. Watch activity when they open it.\n";
+
+ // ─── SharedPreferences Keys ───────────────────────────────────────────────
+ static const String kOnboardingKey = 'nosus_onboarding_done';
+ static const String kGuestModeKey = 'nosus_guest_mode';
+ static const String kUserTypeKey = 'nosus_user_type';
+
+ // ─── Storage Buckets ──────────────────────────────────────────────────────
+ static const String storageSecureFiles = 'secure-files';
+
+ // ─── Audit Statuses ───────────────────────────────────────────────────────
+ static const String auditSuccess = 'SUCCESS';
+ static const String auditSecurity = 'SECURITY';
+ static const String auditInfo = 'INFO';
+}
diff --git a/app/lib/core/layout/app_breakpoints.dart b/app/lib/core/layout/app_breakpoints.dart
new file mode 100644
index 0000000..e079585
--- /dev/null
+++ b/app/lib/core/layout/app_breakpoints.dart
@@ -0,0 +1,14 @@
+import 'package:flutter/material.dart';
+
+/// Layout breakpoints for the Flutter shell.
+///
+/// Compact is the phone/nav-bar layout. Expanded is a side rail plus a
+/// wider content column — not a stretched phone screen.
+abstract final class AppBreakpoints {
+ static const double expanded = 900;
+ static const double contentMaxCompact = 760;
+ static const double contentMaxExpanded = 1180;
+
+ static bool isExpanded(BuildContext context) =>
+ MediaQuery.sizeOf(context).width >= expanded;
+}
diff --git a/app/lib/core/layout/app_layout.dart b/app/lib/core/layout/app_layout.dart
new file mode 100644
index 0000000..3f19134
--- /dev/null
+++ b/app/lib/core/layout/app_layout.dart
@@ -0,0 +1,37 @@
+import 'package:flutter/material.dart';
+
+/// Responsive content widths. Phone layouts stay compact; larger screens
+/// get a readable column rather than a stretched phone UI.
+class AppLayout {
+ AppLayout._();
+
+ static const double phone = 560;
+ static const double tablet = 840;
+ static const double desktop = 1080;
+
+ static double maxContentWidth(BuildContext context) {
+ final width = MediaQuery.sizeOf(context).width;
+ if (width >= 1280) return desktop;
+ if (width >= 840) return tablet;
+ return phone;
+ }
+
+ static bool isWide(BuildContext context) =>
+ MediaQuery.sizeOf(context).width >= 840;
+
+ static bool isDesktop(BuildContext context) =>
+ MediaQuery.sizeOf(context).width >= 1100;
+
+ static Widget constrain(
+ BuildContext context, {
+ required Widget child,
+ double? maxWidth,
+ }) {
+ return Center(
+ child: ConstrainedBox(
+ constraints: BoxConstraints(maxWidth: maxWidth ?? maxContentWidth(context)),
+ child: child,
+ ),
+ );
+ }
+}
diff --git a/app/lib/core/mascot/mascot_controller.dart b/app/lib/core/mascot/mascot_controller.dart
new file mode 100644
index 0000000..39f5a08
--- /dev/null
+++ b/app/lib/core/mascot/mascot_controller.dart
@@ -0,0 +1,77 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:rive/rive.dart';
+
+import 'mascot_state.dart';
+
+/// Drives one mascot artboard's Rive State Machine inputs (`stateIndex`,
+/// `reducedMotion`) from app-level events. [state] tracks the current mood
+/// for anything that wants to observe it; `reducedMotion` is intentionally
+/// kept as a plain field (not Riverpod state) since it's toggled from
+/// [MascotView]'s build method and must never trigger a provider mutation
+/// during a widget build.
+class MascotNotifier extends Notifier {
+ StateMachineController? _smController;
+ SMINumber? _stateIndexInput;
+ SMIBool? _reducedMotionInput;
+ bool _reducedMotion = false;
+
+ @override
+ MascotMood build() {
+ ref.onDispose(() => _smController?.dispose());
+ return MascotMood.idle;
+ }
+
+ /// Called from [MascotView.onInit] once the artboard has loaded.
+ void attach(Artboard artboard) {
+ final controller = StateMachineController.fromArtboard(artboard, 'SM');
+ if (controller == null) return;
+ _smController = controller;
+ artboard.addController(controller);
+ _stateIndexInput = controller.findInput('stateIndex') as SMINumber?;
+ _reducedMotionInput = controller.findInput('reducedMotion') as SMIBool?;
+ _apply();
+ }
+
+ /// Plays a catalogued mood. One-shots are expected to be followed by a
+ /// call back to [MascotMood.idle] by the caller once the moment has passed
+ /// (Rive's `oneShotDone` trigger output can automate this once authored).
+ void play(MascotMood mood) {
+ state = mood;
+ _apply();
+ }
+
+ void setReducedMotion(bool value) {
+ if (_reducedMotion == value) return;
+ _reducedMotion = value;
+ _apply();
+ }
+
+ void _apply() {
+ _reducedMotionInput?.value = _reducedMotion;
+ _stateIndexInput?.value =
+ (_reducedMotion ? MascotMood.idle : state).index.toDouble();
+ }
+}
+
+final luxMascotProvider =
+ NotifierProvider(MascotNotifier.new);
+final noxMascotProvider =
+ NotifierProvider(MascotNotifier.new);
+final duoMascotProvider =
+ NotifierProvider(MascotNotifier.new);
+
+/// Looks up the right provider instance for a given character so call sites
+/// can write `ref.read(mascotProvider(MascotCharacter.nox).notifier)` instead
+/// of branching on the enum themselves.
+NotifierProvider mascotProvider(
+ MascotCharacter character,
+) {
+ switch (character) {
+ case MascotCharacter.lux:
+ return luxMascotProvider;
+ case MascotCharacter.nox:
+ return noxMascotProvider;
+ case MascotCharacter.duo:
+ return duoMascotProvider;
+ }
+}
diff --git a/app/lib/core/mascot/mascot_state.dart b/app/lib/core/mascot/mascot_state.dart
new file mode 100644
index 0000000..e936572
--- /dev/null
+++ b/app/lib/core/mascot/mascot_state.dart
@@ -0,0 +1,29 @@
+/// Which mascot artboard/state-machine to drive. `duo` is reserved for the
+/// scarce together-moments (session bookends) — see docs/design/MASCOT_MOTION_BIBLE.md.
+enum MascotCharacter { lux, nox, duo }
+
+/// Union of every catalogued animation state across Lux and Nox. Not every
+/// mood applies to every character — a character's .riv simply won't define
+/// an animation for moods it doesn't use, and the state machine holds on
+/// Idle instead. Order matters: it is the `stateIndex` sent to Rive.
+enum MascotMood {
+ idle,
+ sleep,
+ wake,
+ walk,
+ lookAround,
+ think,
+ observe,
+ guide,
+ celebrate,
+ wait,
+ sit,
+ stretch,
+ protect,
+ approve,
+ verify,
+ guard,
+ alert,
+ stamp,
+ returnToLogo,
+}
diff --git a/app/lib/core/mascot/mascot_view.dart b/app/lib/core/mascot/mascot_view.dart
new file mode 100644
index 0000000..a9f70cb
--- /dev/null
+++ b/app/lib/core/mascot/mascot_view.dart
@@ -0,0 +1,96 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:rive/rive.dart' as rive;
+
+import 'mascot_controller.dart';
+import 'mascot_state.dart';
+
+/// One load attempt per character per app run, shared by every [MascotView]
+/// instance. Resolves to null when the .riv doesn't exist yet — that's the
+/// designed pre-asset state, so it must NOT surface as an unhandled async
+/// error (RiveAnimation.asset throws one into the root zone on every mount
+/// otherwise, polluting production logs).
+final _riveFileCache = >{};
+
+Future _loadRiveFile(MascotCharacter character) {
+ return _riveFileCache.putIfAbsent(character, () async {
+ try {
+ return await rive.RiveFile.asset('assets/mascot/${character.name}.riv');
+ } catch (_) {
+ return null;
+ }
+ });
+}
+
+/// Renders one mascot (Lux, Nox, or the scarce Duo bookend composition),
+/// driven imperatively by [mascotProvider] — call
+/// `ref.read(mascotProvider(character).notifier).play(mood)` from wherever
+/// the app state change happens; this widget doesn't need to rebuild for
+/// that to take effect.
+///
+/// Looks up `assets/mascot/.riv` by convention (see
+/// assets/mascot/README.md). Until that file exists, Rive's own
+/// `placeHolder` mechanism renders instead — nothing for solo Lux/Nox, the
+/// existing static logo mark for the Duo — so every call site below is safe
+/// to ship today, before any real Rive asset has been authored.
+class MascotView extends ConsumerWidget {
+ final MascotCharacter character;
+ final double size;
+
+ /// Only set this when the mascot is the *sole* carrier of a state change
+ /// not already reflected in surrounding text/UI — per the accessibility
+ /// rule in docs/design/MASCOT_MOTION_BIBLE.md, that should be rare.
+ final String? semanticLabel;
+
+ /// What to show before a real `.riv` asset exists for [character]. Defaults
+ /// to nothing for solo Lux/Nox (the mascot simply isn't visible yet) — but
+ /// pass the widget being *replaced* at a call site (e.g. an existing empty
+ /// -state icon) so today's UI doesn't lose content while waiting on real
+ /// mascot art.
+ final Widget? fallback;
+
+ const MascotView({
+ super.key,
+ required this.character,
+ this.size = 48,
+ this.semanticLabel,
+ this.fallback,
+ });
+
+ Widget get _placeholder {
+ if (fallback != null) return fallback!;
+ if (character == MascotCharacter.duo) {
+ return Image.asset('assets/icon/LuxandNox.png', fit: BoxFit.contain);
+ }
+ return const SizedBox.shrink();
+ }
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final reducedMotion = MediaQuery.of(context).disableAnimations;
+ final notifier = ref.read(mascotProvider(character).notifier);
+ notifier.setReducedMotion(reducedMotion);
+
+ return Semantics(
+ excludeSemantics: semanticLabel == null,
+ label: semanticLabel,
+ child: SizedBox(
+ width: size,
+ height: size,
+ child: FutureBuilder(
+ future: _loadRiveFile(character),
+ builder: (context, snapshot) {
+ final file = snapshot.data;
+ if (file == null) return _placeholder;
+ return rive.RiveAnimation.direct(
+ file,
+ stateMachines: const ['SM'],
+ fit: BoxFit.contain,
+ onInit: (artboard) => notifier.attach(artboard),
+ );
+ },
+ ),
+ ),
+ );
+ }
+}
diff --git a/app/lib/core/providers/theme_provider.dart b/app/lib/core/providers/theme_provider.dart
new file mode 100644
index 0000000..1d4346a
--- /dev/null
+++ b/app/lib/core/providers/theme_provider.dart
@@ -0,0 +1,46 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+/// Provider exposing the pre-initialized SharedPreferences instance.
+/// Overridden in the root ProviderScope at app boot.
+final sharedPreferencesProvider = Provider((ref) {
+ throw UnimplementedError('SharedPreferences has not been pre-initialized');
+});
+
+/// Global theme mode provider — replaces the `setState`-based `_themeMode` in MyApp.
+///
+/// Reads SharedPreferences synchronously from the provider to prevent startup flicker.
+class ThemeModeNotifier extends Notifier {
+ static const _key = 'nosus_theme_mode';
+
+ @override
+ ThemeMode build() {
+ try {
+ final prefs = ref.watch(sharedPreferencesProvider);
+ final val = prefs.getString(_key);
+ return val == 'light' ? ThemeMode.light : ThemeMode.dark;
+ } catch (_) {
+ return ThemeMode.dark; // Fallback default
+ }
+ }
+
+ Future toggle() async {
+ final newMode = state == ThemeMode.light ? ThemeMode.dark : ThemeMode.light;
+ state = newMode;
+ try {
+ final prefs = ref.read(sharedPreferencesProvider);
+ await prefs.setString(_key, newMode == ThemeMode.light ? 'light' : 'dark');
+ } catch (_) {}
+ }
+
+ Future setMode(ThemeMode mode) async {
+ state = mode;
+ try {
+ final prefs = ref.read(sharedPreferencesProvider);
+ await prefs.setString(_key, mode == ThemeMode.light ? 'light' : 'dark');
+ } catch (_) {}
+ }
+}
+
+final themeModeProvider = NotifierProvider(ThemeModeNotifier.new);
diff --git a/app/lib/core/supabase/supabase_bootstrap.dart b/app/lib/core/supabase/supabase_bootstrap.dart
new file mode 100644
index 0000000..c9c993a
--- /dev/null
+++ b/app/lib/core/supabase/supabase_bootstrap.dart
@@ -0,0 +1,34 @@
+import 'dart:async';
+
+import 'package:supabase_flutter/supabase_flutter.dart';
+
+import '../../config/supabase_credentials.dart';
+import '../auth/session_recovery.dart';
+
+/// Owns the one-time Supabase SDK initialization for the application.
+final class SupabaseBootstrap {
+ const SupabaseBootstrap._();
+
+ static bool get isConfigured =>
+ SupabaseCredentials.url.isNotEmpty &&
+ SupabaseCredentials.anonKey.isNotEmpty;
+
+ static Future initialize() async {
+ if (!isConfigured) {
+ throw StateError('Supabase credentials are not configured.');
+ }
+
+ await Supabase.initialize(
+ url: SupabaseCredentials.url,
+ publishableKey: SupabaseCredentials.anonKey,
+ authOptions: const FlutterAuthClientOptions(
+ authFlowType: AuthFlowType.pkce,
+ autoRefreshToken: true,
+ detectSessionInUri: true,
+ ),
+ );
+ // A persisted session is usable immediately. If its access token is stale,
+ // refresh without making the first Flutter frame wait for the network.
+ unawaited(SessionRecovery.recoverIfNeeded(Supabase.instance.client.auth));
+ }
+}
diff --git a/app/lib/core/supabase/supabase_providers.dart b/app/lib/core/supabase/supabase_providers.dart
new file mode 100644
index 0000000..445e93b
--- /dev/null
+++ b/app/lib/core/supabase/supabase_providers.dart
@@ -0,0 +1,10 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:supabase_flutter/supabase_flutter.dart';
+import '../../services/supabase_service.dart';
+
+final supabaseClientProvider = Provider((ref) {
+ if (!SupabaseService.instance.isReachable) {
+ throw StateError('Supabase is offline');
+ }
+ return Supabase.instance.client;
+});
diff --git a/app/lib/core/utils/debug_logger.dart b/app/lib/core/utils/debug_logger.dart
new file mode 100644
index 0000000..b9d2102
--- /dev/null
+++ b/app/lib/core/utils/debug_logger.dart
@@ -0,0 +1,7 @@
+import 'package:flutter/foundation.dart';
+
+void debugLog(String message) {
+ if (kDebugMode) {
+ debugPrint(message);
+ }
+}
diff --git a/app/lib/core/utils/web_links.dart b/app/lib/core/utils/web_links.dart
new file mode 100644
index 0000000..4633942
--- /dev/null
+++ b/app/lib/core/utils/web_links.dart
@@ -0,0 +1,36 @@
+import 'package:flutter/foundation.dart';
+
+/// Where the Flutter web app lives in production. The marketing landing page
+/// owns the bare `nosus.foo` root; the app is served from this subdomain.
+/// The landing page carries a forwarding shim, so legacy `nosus.foo/#/...`
+/// links (shared before the split) still resolve into the app here.
+/// Override at build time for the APK, e.g.
+/// --dart-define=WEB_APP_ORIGIN=https://https-shubhamsahu.github.io
+const String kWebAppOrigin = String.fromEnvironment(
+ 'WEB_APP_ORIGIN',
+ defaultValue: 'http://shubham-sahu.me',
+);
+
+/// The path prefix the Flutter web app is deployed under on [kWebAppOrigin].
+/// Empty — the app sits at the subdomain root.
+/// Override at build time, e.g. --dart-define=WEB_APP_BASE_PATH=/nosus-canary
+/// (leading slash, no trailing slash).
+const String kWebAppBasePath = String.fromEnvironment('WEB_APP_BASE_PATH');
+
+/// Origin + base path to prefix any deep link shared out of the app with.
+///
+/// On web, [Uri.base] already reflects wherever this session is actually
+/// running (a local dev server, a PR preview, production) so the base path
+/// is derived from the current URL rather than hardcoded. On native, there
+/// is no "current URL": every generated link must point at the fixed
+/// production location instead, via [kWebAppOrigin]/[kWebAppBasePath].
+({String origin, String basePath}) webShareLinkBase() {
+ if (kIsWeb) {
+ final origin = Uri.base.origin;
+ final basePath = Uri.base.path
+ .replaceAll('index.html', '')
+ .replaceAll(RegExp(r'/$'), '');
+ return (origin: origin, basePath: basePath);
+ }
+ return (origin: kWebAppOrigin, basePath: kWebAppBasePath);
+}
diff --git a/app/lib/features/admin/presentation/screens/admin_dashboard_screen.dart b/app/lib/features/admin/presentation/screens/admin_dashboard_screen.dart
new file mode 100644
index 0000000..b9200be
--- /dev/null
+++ b/app/lib/features/admin/presentation/screens/admin_dashboard_screen.dart
@@ -0,0 +1,754 @@
+import 'dart:convert';
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:supabase_flutter/supabase_flutter.dart';
+import '../../../../theme.dart';
+import '../../../auth/presentation/providers/auth_providers.dart';
+import '../../../config/presentation/providers/config_provider.dart';
+import '../../../../core/utils/debug_logger.dart';
+
+/// The Super Admin Console for Platform Operations.
+/// Accessible only to authenticated users with 'admin' or 'super_admin' roles.
+class AdminDashboardScreen extends ConsumerStatefulWidget {
+ const AdminDashboardScreen({super.key});
+
+ @override
+ ConsumerState createState() => _AdminDashboardScreenState();
+}
+
+class _AdminDashboardScreenState extends ConsumerState
+ with SingleTickerProviderStateMixin {
+ late TabController _tabController;
+ final _supabase = Supabase.instance.client;
+
+ // State for feature flags
+ List