Compare commits
48 Commits
56c4b1bc03
...
89822dedb8
| Author | SHA1 | Date | |
|---|---|---|---|
| 89822dedb8 | |||
| 3ccaeaa2e3 | |||
| a90534e164 | |||
| 325be03797 | |||
| 35bc5f4af1 | |||
| 78c63f018c | |||
| 8cf26e04af | |||
| 7625d0caea | |||
| 0fc7b2e745 | |||
| a5aeace438 | |||
| b03096f19d | |||
| 20dc5bf785 | |||
| c02457c66a | |||
| 3a8e329f02 | |||
| 7cbcde6a6b | |||
| eb8e57c674 | |||
| 659ab9b71a | |||
| 4f7882a10d | |||
| d84595bf72 | |||
| a3fee924d8 | |||
| fc9a5c4fb2 | |||
| e6b07ddf1d | |||
| bec8cbf269 | |||
| b2c3470a71 | |||
| 5154990acd | |||
| 40a9ef146c | |||
| 28c33a930d | |||
| 71972436b6 | |||
| 052e08c17b | |||
| bc20aeaeb6 | |||
| 9dc55517b1 | |||
| 25da0cd687 | |||
| 6acbb6ca37 | |||
| 3f00dd6b28 | |||
| d4c1b62a97 | |||
| c9a82fc6de | |||
| 6981a05de4 | |||
| 6002ea383b | |||
| 3842a20b35 | |||
| cc41f4ad32 | |||
| ee31b88612 | |||
| aa69c0ecc4 | |||
| 4118a25388 | |||
| 06bf9ac97c | |||
| f627033665 | |||
| 59fcc31483 | |||
| 24459442a2 | |||
| 4471719b79 |
12
.editorconfig
Normal file
12
.editorconfig
Normal file
@@ -0,0 +1,12 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
25
.github/workflows/ci.yml
vendored
25
.github/workflows/ci.yml
vendored
@@ -89,8 +89,8 @@ jobs:
|
||||
version: ${{ env.PNPM_VERSION }}
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Run tests with coverage
|
||||
run: pnpm test:coverage
|
||||
- name: Run tests
|
||||
run: pnpm test
|
||||
env:
|
||||
DATABASE_URL: "postgresql://shieldai:shieldai_dev@localhost:5432/shieldai"
|
||||
REDIS_URL: "redis://localhost:6379"
|
||||
@@ -106,22 +106,6 @@ jobs:
|
||||
name: Docker Build
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, typecheck, test]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: api
|
||||
context: .
|
||||
dockerfile: packages/api/Dockerfile
|
||||
- name: darkwatch
|
||||
context: .
|
||||
dockerfile: services/darkwatch/Dockerfile
|
||||
- name: spamshield
|
||||
context: .
|
||||
dockerfile: services/spamshield/Dockerfile
|
||||
- name: voiceprint
|
||||
context: .
|
||||
dockerfile: services/voiceprint/Dockerfile
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Docker Buildx
|
||||
@@ -129,10 +113,9 @@ jobs:
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ${{ matrix.context }}
|
||||
file: ${{ matrix.dockerfile }}
|
||||
context: .
|
||||
push: false
|
||||
tags: shieldai-${{ matrix.name }}:${{ github.sha }}
|
||||
tags: shieldai:${{ github.sha }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,7 +1,9 @@
|
||||
node_modules
|
||||
dist
|
||||
.output
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
load-tests/voiceprint/results/
|
||||
.turbo
|
||||
.nitro
|
||||
package-lock.json
|
||||
|
||||
1
.turbo/cache/47854326d2b77c8e-manifest.json
vendored
1
.turbo/cache/47854326d2b77c8e-manifest.json
vendored
@@ -1 +0,0 @@
|
||||
{"files":{"packages/types/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/types/dist/index.js":{"size":3531,"mtime_nanos":1778380725084978870,"mode":420,"is_dir":false},"packages/types/dist/index.js.map":{"size":2294,"mtime_nanos":1778380725084978870,"mode":420,"is_dir":false},"packages/types/dist/requestId.d.ts.map":{"size":278,"mtime_nanos":1778380725078978662,"mode":420,"is_dir":false},"packages/types/dist/requestId.d.ts":{"size":629,"mtime_nanos":1778380725078978662,"mode":420,"is_dir":false},"packages/types/dist/requestId.js":{"size":2329,"mtime_nanos":1778380725074978523,"mode":420,"is_dir":false},"packages/types/dist/requestId.js.map":{"size":1785,"mtime_nanos":1778380725074978523,"mode":420,"is_dir":false},"packages/types/.turbo/turbo-build.log":{"size":78,"mtime_nanos":1778380725118980048,"mode":420,"is_dir":false},"packages/types/dist/index.d.ts.map":{"size":7296,"mtime_nanos":1778380725099979390,"mode":420,"is_dir":false},"packages/types/dist/index.d.ts":{"size":9902,"mtime_nanos":1778380725099979390,"mode":420,"is_dir":false}},"order":["packages/types/.turbo/turbo-build.log","packages/types/dist","packages/types/dist/index.d.ts","packages/types/dist/index.d.ts.map","packages/types/dist/index.js","packages/types/dist/index.js.map","packages/types/dist/requestId.d.ts","packages/types/dist/requestId.d.ts.map","packages/types/dist/requestId.js","packages/types/dist/requestId.js.map"]}
|
||||
1
.turbo/cache/47854326d2b77c8e-meta.json
vendored
1
.turbo/cache/47854326d2b77c8e-meta.json
vendored
@@ -1 +0,0 @@
|
||||
{"hash":"47854326d2b77c8e","duration":744,"sha":"de0ddac65df311d7ef051c48ad6291d8de8618f3","dirty_hash":"a8bcf9ec37f7505b9b259118f068359e59ffb7bdae53135b3b2ec7ca027f5c2d"}
|
||||
BIN
.turbo/cache/47854326d2b77c8e.tar.zst
vendored
BIN
.turbo/cache/47854326d2b77c8e.tar.zst
vendored
Binary file not shown.
1
.turbo/cache/6abb2efbabfd492c-manifest.json
vendored
1
.turbo/cache/6abb2efbabfd492c-manifest.json
vendored
@@ -1 +0,0 @@
|
||||
{"files":{"packages/types/dist/index.d.ts":{"size":7670,"mtime_nanos":1777817946251116749,"mode":420,"is_dir":false},"packages/types/dist/requestId.js.map":{"size":1785,"mtime_nanos":1777817946232116132,"mode":420,"is_dir":false},"packages/types/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/types/.turbo/turbo-build.log":{"size":78,"mtime_nanos":1777817946270117366,"mode":420,"is_dir":false},"packages/types/dist/index.js":{"size":3106,"mtime_nanos":1777817946240116392,"mode":420,"is_dir":false},"packages/types/dist/requestId.d.ts":{"size":629,"mtime_nanos":1777817946235116229,"mode":420,"is_dir":false},"packages/types/dist/requestId.js":{"size":2329,"mtime_nanos":1777817946232116132,"mode":420,"is_dir":false},"packages/types/dist/requestId.d.ts.map":{"size":278,"mtime_nanos":1777817946235116229,"mode":420,"is_dir":false},"packages/types/dist/index.js.map":{"size":2044,"mtime_nanos":1777817946240116392,"mode":420,"is_dir":false},"packages/types/dist/index.d.ts.map":{"size":5437,"mtime_nanos":1777817946251116749,"mode":420,"is_dir":false}},"order":["packages/types/.turbo/turbo-build.log","packages/types/dist","packages/types/dist/index.d.ts","packages/types/dist/index.d.ts.map","packages/types/dist/index.js","packages/types/dist/index.js.map","packages/types/dist/requestId.d.ts","packages/types/dist/requestId.d.ts.map","packages/types/dist/requestId.js","packages/types/dist/requestId.js.map"]}
|
||||
1
.turbo/cache/6abb2efbabfd492c-meta.json
vendored
1
.turbo/cache/6abb2efbabfd492c-meta.json
vendored
@@ -1 +0,0 @@
|
||||
{"hash":"6abb2efbabfd492c","duration":728,"sha":"a4684e912110fdf2702981e23494be96df91b86f","dirty_hash":"85a4cfa756e84c777eeff88ca5a3d970b636968eb72658995bfec15eeba2d9b4"}
|
||||
BIN
.turbo/cache/6abb2efbabfd492c.tar.zst
vendored
BIN
.turbo/cache/6abb2efbabfd492c.tar.zst
vendored
Binary file not shown.
1
.turbo/cache/8ff5b7eb9e0aad01-manifest.json
vendored
1
.turbo/cache/8ff5b7eb9e0aad01-manifest.json
vendored
@@ -1 +0,0 @@
|
||||
{"files":{"packages/correlation/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/correlation/dist/engine.js.map":{"size":9890,"mtime_nanos":1777721551087749490,"mode":420,"is_dir":false},"packages/correlation/dist/index.js":{"size":1909,"mtime_nanos":1777721551102749905,"mode":420,"is_dir":false},"packages/correlation/.turbo/turbo-build.log":{"size":90,"mtime_nanos":1777721551125750542,"mode":420,"is_dir":false},"packages/correlation/dist/service.d.ts.map":{"size":2091,"mtime_nanos":1777721551100749850,"mode":420,"is_dir":false},"packages/correlation/dist/index.d.ts.map":{"size":346,"mtime_nanos":1777721551102749905,"mode":420,"is_dir":false},"packages/correlation/dist/index.js.map":{"size":388,"mtime_nanos":1777721551102749905,"mode":420,"is_dir":false},"packages/correlation/dist/normalizer.js":{"size":6535,"mtime_nanos":1777721551064748853,"mode":420,"is_dir":false},"packages/correlation/dist/service.js":{"size":2496,"mtime_nanos":1777721551093749656,"mode":420,"is_dir":false},"packages/correlation/dist/index.d.ts":{"size":347,"mtime_nanos":1777721551102749905,"mode":420,"is_dir":false},"packages/correlation/dist/engine.js":{"size":10672,"mtime_nanos":1777721551087749490,"mode":420,"is_dir":false},"packages/correlation/dist/engine.d.ts.map":{"size":1146,"mtime_nanos":1777721551089749545,"mode":420,"is_dir":false},"packages/correlation/dist/normalizer.d.ts":{"size":1601,"mtime_nanos":1777721551071749047,"mode":420,"is_dir":false},"packages/correlation/dist/normalizer.d.ts.map":{"size":1561,"mtime_nanos":1777721551071749047,"mode":420,"is_dir":false},"packages/correlation/dist/service.d.ts":{"size":2700,"mtime_nanos":1777721551100749850,"mode":420,"is_dir":false},"packages/correlation/dist/engine.d.ts":{"size":1292,"mtime_nanos":1777721551089749545,"mode":420,"is_dir":false},"packages/correlation/dist/emitter.js":{"size":2425,"mtime_nanos":1777721551105749988,"mode":420,"is_dir":false},"packages/correlation/dist/service.js.map":{"size":1947,"mtime_nanos":1777721551093749656,"mode":420,"is_dir":false},"packages/correlation/dist/emitter.d.ts":{"size":946,"mtime_nanos":1777721551106750016,"mode":420,"is_dir":false},"packages/correlation/dist/emitter.js.map":{"size":1719,"mtime_nanos":1777721551105749988,"mode":420,"is_dir":false},"packages/correlation/dist/emitter.d.ts.map":{"size":1092,"mtime_nanos":1777721551106750016,"mode":420,"is_dir":false},"packages/correlation/dist/normalizer.js.map":{"size":5180,"mtime_nanos":1777721551063748825,"mode":420,"is_dir":false}},"order":["packages/correlation/.turbo/turbo-build.log","packages/correlation/dist","packages/correlation/dist/emitter.d.ts","packages/correlation/dist/emitter.d.ts.map","packages/correlation/dist/emitter.js","packages/correlation/dist/emitter.js.map","packages/correlation/dist/engine.d.ts","packages/correlation/dist/engine.d.ts.map","packages/correlation/dist/engine.js","packages/correlation/dist/engine.js.map","packages/correlation/dist/index.d.ts","packages/correlation/dist/index.d.ts.map","packages/correlation/dist/index.js","packages/correlation/dist/index.js.map","packages/correlation/dist/normalizer.d.ts","packages/correlation/dist/normalizer.d.ts.map","packages/correlation/dist/normalizer.js","packages/correlation/dist/normalizer.js.map","packages/correlation/dist/service.d.ts","packages/correlation/dist/service.d.ts.map","packages/correlation/dist/service.js","packages/correlation/dist/service.js.map"]}
|
||||
1
.turbo/cache/8ff5b7eb9e0aad01-meta.json
vendored
1
.turbo/cache/8ff5b7eb9e0aad01-meta.json
vendored
@@ -1 +0,0 @@
|
||||
{"hash":"8ff5b7eb9e0aad01","duration":908,"sha":"b01b79d02a41aac425fe0f4ab3e21460c69a94b4","dirty_hash":"53949d4fa912af90b4184926009d1814809e1d773d20612a89c885dbf200727c"}
|
||||
BIN
.turbo/cache/8ff5b7eb9e0aad01.tar.zst
vendored
BIN
.turbo/cache/8ff5b7eb9e0aad01.tar.zst
vendored
Binary file not shown.
1
.turbo/cache/aacbad09f9d0c28b-manifest.json
vendored
1
.turbo/cache/aacbad09f9d0c28b-manifest.json
vendored
@@ -1 +0,0 @@
|
||||
{"files":{"packages/db/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/db/dist/services/field-encryption.service.d.ts.map":{"size":330,"mtime_nanos":1777698592443009097,"mode":420,"is_dir":false},"packages/db/.turbo/turbo-build.log":{"size":511,"mtime_nanos":1777698592481009929,"mode":420,"is_dir":false},"packages/db/dist/index.js":{"size":535,"mtime_nanos":1777698592446009163,"mode":420,"is_dir":false},"packages/db/dist/services/field-encryption.service.d.ts":{"size":252,"mtime_nanos":1777698592443009097,"mode":420,"is_dir":false},"packages/db/dist/index.js.map":{"size":217,"mtime_nanos":1777698592446009163,"mode":420,"is_dir":false},"packages/db/dist/services/field-encryption.service.js":{"size":1606,"mtime_nanos":1777698592439009009,"mode":420,"is_dir":false},"packages/db/dist/services/field-encryption.service.js.map":{"size":1414,"mtime_nanos":1777698592439009009,"mode":420,"is_dir":false},"packages/db/dist/services":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/db/dist/index.d.ts.map":{"size":308,"mtime_nanos":1777698592459009447,"mode":420,"is_dir":false},"packages/db/dist/index.d.ts":{"size":405,"mtime_nanos":1777698592459009447,"mode":420,"is_dir":false}},"order":["packages/db/.turbo/turbo-build.log","packages/db/dist","packages/db/dist/index.d.ts","packages/db/dist/index.d.ts.map","packages/db/dist/index.js","packages/db/dist/index.js.map","packages/db/dist/services","packages/db/dist/services/field-encryption.service.d.ts","packages/db/dist/services/field-encryption.service.d.ts.map","packages/db/dist/services/field-encryption.service.js","packages/db/dist/services/field-encryption.service.js.map"]}
|
||||
1
.turbo/cache/aacbad09f9d0c28b-meta.json
vendored
1
.turbo/cache/aacbad09f9d0c28b-meta.json
vendored
@@ -1 +0,0 @@
|
||||
{"hash":"aacbad09f9d0c28b","duration":1972,"sha":"685fb57e53b5d01707795f6ec6f119356e0bfd12","dirty_hash":"0908f7ed09b46b26ba2dfc1c94e994cefe9e2f178fad10e9c8483f8ee168d061"}
|
||||
BIN
.turbo/cache/aacbad09f9d0c28b.tar.zst
vendored
BIN
.turbo/cache/aacbad09f9d0c28b.tar.zst
vendored
Binary file not shown.
1
.turbo/cache/dbd09b3775d9469c-manifest.json
vendored
1
.turbo/cache/dbd09b3775d9469c-manifest.json
vendored
@@ -1 +0,0 @@
|
||||
{"files":{"packages/types/.turbo/turbo-build.log":{"size":78,"mtime_nanos":1777698591363985482,"mode":420,"is_dir":false},"packages/types/dist/index.d.ts.map":{"size":5437,"mtime_nanos":1777698591336984892,"mode":420,"is_dir":false},"packages/types/dist/requestId.d.ts":{"size":519,"mtime_nanos":1777698591309984301,"mode":420,"is_dir":false},"packages/types/dist/requestId.d.ts.map":{"size":276,"mtime_nanos":1777698591309984301,"mode":420,"is_dir":false},"packages/types/dist/requestId.js":{"size":1383,"mtime_nanos":1777698591304984191,"mode":420,"is_dir":false},"packages/types/dist/index.d.ts":{"size":7670,"mtime_nanos":1777698591336984892,"mode":420,"is_dir":false},"packages/types/dist/index.js.map":{"size":2044,"mtime_nanos":1777698591318984498,"mode":420,"is_dir":false},"packages/types/dist/requestId.js.map":{"size":1299,"mtime_nanos":1777698591304984191,"mode":420,"is_dir":false},"packages/types/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/types/dist/index.js":{"size":3106,"mtime_nanos":1777698591319984520,"mode":420,"is_dir":false}},"order":["packages/types/.turbo/turbo-build.log","packages/types/dist","packages/types/dist/index.d.ts","packages/types/dist/index.d.ts.map","packages/types/dist/index.js","packages/types/dist/index.js.map","packages/types/dist/requestId.d.ts","packages/types/dist/requestId.d.ts.map","packages/types/dist/requestId.js","packages/types/dist/requestId.js.map"]}
|
||||
1
.turbo/cache/dbd09b3775d9469c-meta.json
vendored
1
.turbo/cache/dbd09b3775d9469c-meta.json
vendored
@@ -1 +0,0 @@
|
||||
{"hash":"dbd09b3775d9469c","duration":855,"sha":"685fb57e53b5d01707795f6ec6f119356e0bfd12","dirty_hash":"0908f7ed09b46b26ba2dfc1c94e994cefe9e2f178fad10e9c8483f8ee168d061"}
|
||||
BIN
.turbo/cache/dbd09b3775d9469c.tar.zst
vendored
BIN
.turbo/cache/dbd09b3775d9469c.tar.zst
vendored
Binary file not shown.
1
.turbo/cache/df12164dc3180a8f-manifest.json
vendored
1
.turbo/cache/df12164dc3180a8f-manifest.json
vendored
@@ -1 +0,0 @@
|
||||
{"files":{"packages/db/dist/index.d.ts":{"size":405,"mtime_nanos":1777721550197724849,"mode":420,"is_dir":false},"packages/db/dist/services/field-encryption.service.d.ts.map":{"size":330,"mtime_nanos":1777721550183724462,"mode":420,"is_dir":false},"packages/db/dist/services/field-encryption.service.js.map":{"size":1414,"mtime_nanos":1777721550180724379,"mode":420,"is_dir":false},"packages/db/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/db/dist/index.d.ts.map":{"size":308,"mtime_nanos":1777721550197724849,"mode":420,"is_dir":false},"packages/db/dist/services/field-encryption.service.js":{"size":1606,"mtime_nanos":1777721550180724379,"mode":420,"is_dir":false},"packages/db/.turbo/turbo-build.log":{"size":1379,"mtime_nanos":1777721550215725348,"mode":420,"is_dir":false},"packages/db/dist/services":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/db/dist/services/field-encryption.service.d.ts":{"size":252,"mtime_nanos":1777721550183724462,"mode":420,"is_dir":false},"packages/db/dist/index.js":{"size":535,"mtime_nanos":1777721550186724545,"mode":420,"is_dir":false},"packages/db/dist/index.js.map":{"size":217,"mtime_nanos":1777721550186724545,"mode":420,"is_dir":false}},"order":["packages/db/.turbo/turbo-build.log","packages/db/dist","packages/db/dist/index.d.ts","packages/db/dist/index.d.ts.map","packages/db/dist/index.js","packages/db/dist/index.js.map","packages/db/dist/services","packages/db/dist/services/field-encryption.service.d.ts","packages/db/dist/services/field-encryption.service.d.ts.map","packages/db/dist/services/field-encryption.service.js","packages/db/dist/services/field-encryption.service.js.map"]}
|
||||
1
.turbo/cache/df12164dc3180a8f-meta.json
vendored
1
.turbo/cache/df12164dc3180a8f-meta.json
vendored
@@ -1 +0,0 @@
|
||||
{"hash":"df12164dc3180a8f","duration":1557,"sha":"b01b79d02a41aac425fe0f4ab3e21460c69a94b4","dirty_hash":"53949d4fa912af90b4184926009d1814809e1d773d20612a89c885dbf200727c"}
|
||||
BIN
.turbo/cache/df12164dc3180a8f.tar.zst
vendored
BIN
.turbo/cache/df12164dc3180a8f.tar.zst
vendored
Binary file not shown.
1
.turbo/cache/df8d582601d96e8d-manifest.json
vendored
1
.turbo/cache/df8d582601d96e8d-manifest.json
vendored
@@ -1 +0,0 @@
|
||||
{"files":{"packages/types/dist/index.js":{"size":3106,"mtime_nanos":1777754191886389843,"mode":420,"is_dir":false},"packages/types/dist/requestId.d.ts":{"size":629,"mtime_nanos":1777754191880389688,"mode":420,"is_dir":false},"packages/types/dist/index.d.ts":{"size":7670,"mtime_nanos":1777754191897390127,"mode":420,"is_dir":false},"packages/types/dist/index.js.map":{"size":2044,"mtime_nanos":1777754191886389843,"mode":420,"is_dir":false},"packages/types/dist/index.d.ts.map":{"size":5437,"mtime_nanos":1777754191897390127,"mode":420,"is_dir":false},"packages/types/dist/requestId.d.ts.map":{"size":278,"mtime_nanos":1777754191880389688,"mode":420,"is_dir":false},"packages/types/.turbo/turbo-build.log":{"size":78,"mtime_nanos":1777754191919390695,"mode":420,"is_dir":false},"packages/types/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/types/dist/requestId.js.map":{"size":1785,"mtime_nanos":1777754191876389585,"mode":420,"is_dir":false},"packages/types/dist/requestId.js":{"size":2329,"mtime_nanos":1777754191876389585,"mode":420,"is_dir":false}},"order":["packages/types/.turbo/turbo-build.log","packages/types/dist","packages/types/dist/index.d.ts","packages/types/dist/index.d.ts.map","packages/types/dist/index.js","packages/types/dist/index.js.map","packages/types/dist/requestId.d.ts","packages/types/dist/requestId.d.ts.map","packages/types/dist/requestId.js","packages/types/dist/requestId.js.map"]}
|
||||
1
.turbo/cache/df8d582601d96e8d-meta.json
vendored
1
.turbo/cache/df8d582601d96e8d-meta.json
vendored
@@ -1 +0,0 @@
|
||||
{"hash":"df8d582601d96e8d","duration":684,"sha":"274afa63352200107e5e3ed5a783555fe3c68e37","dirty_hash":"1b22568f1b7a3df274940e36b290211b3251b700c1e1286bc843ed3e00b07e05"}
|
||||
BIN
.turbo/cache/df8d582601d96e8d.tar.zst
vendored
BIN
.turbo/cache/df8d582601d96e8d.tar.zst
vendored
Binary file not shown.
1
.turbo/cache/f810866ff5911e6a-manifest.json
vendored
1
.turbo/cache/f810866ff5911e6a-manifest.json
vendored
@@ -1 +0,0 @@
|
||||
{"files":{"packages/shared-billing/dist/models/subscription.model.js":{"size":1577,"mtime_nanos":1777698591971998787,"mode":420,"is_dir":false},"packages/shared-billing/dist/middleware":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/shared-billing/dist/config/billing.config.js":{"size":3740,"mtime_nanos":1777698591945998218,"mode":420,"is_dir":false},"packages/shared-billing/dist/services/billing.service.d.ts":{"size":2511,"mtime_nanos":1777698592000999421,"mode":420,"is_dir":false},"packages/shared-billing/dist/services/billing.service.d.ts.map":{"size":1804,"mtime_nanos":1777698592000999421,"mode":420,"is_dir":false},"packages/shared-billing/dist/services/billing.service.js.map":{"size":6458,"mtime_nanos":1777698591993999268,"mode":420,"is_dir":false},"packages/shared-billing/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/shared-billing/dist/config/billing.config.d.ts":{"size":8876,"mtime_nanos":1777698591967998699,"mode":420,"is_dir":false},"packages/shared-billing/dist/index.js":{"size":2386,"mtime_nanos":1777698592015999750,"mode":420,"is_dir":false},"packages/shared-billing/dist/config":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/shared-billing/dist/index.js.map":{"size":352,"mtime_nanos":1777698592015999750,"mode":420,"is_dir":false},"packages/shared-billing/dist/models/subscription.model.d.ts":{"size":3467,"mtime_nanos":1777698591977998918,"mode":420,"is_dir":false},"packages/shared-billing/dist/models/subscription.model.js.map":{"size":1431,"mtime_nanos":1777698591971998787,"mode":420,"is_dir":false},"packages/shared-billing/dist/middleware/billing.middleware.d.ts.map":{"size":1125,"mtime_nanos":1777698592011999662,"mode":420,"is_dir":false},"packages/shared-billing/dist/middleware/billing.middleware.js":{"size":4164,"mtime_nanos":1777698592006999552,"mode":420,"is_dir":false},"packages/shared-billing/dist/models":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/shared-billing/dist/models/subscription.model.d.ts.map":{"size":434,"mtime_nanos":1777698591976998896,"mode":420,"is_dir":false},"packages/shared-billing/dist/services/billing.service.js":{"size":7312,"mtime_nanos":1777698591993999268,"mode":420,"is_dir":false},"packages/shared-billing/dist/index.d.ts":{"size":359,"mtime_nanos":1777698592015999750,"mode":420,"is_dir":false},"packages/shared-billing/dist/config/billing.config.d.ts.map":{"size":664,"mtime_nanos":1777698591967998699,"mode":420,"is_dir":false},"packages/shared-billing/dist/middleware/billing.middleware.d.ts":{"size":1176,"mtime_nanos":1777698592011999662,"mode":420,"is_dir":false},"packages/shared-billing/.turbo/turbo-build.log":{"size":96,"mtime_nanos":1777698592050000494,"mode":420,"is_dir":false},"packages/shared-billing/dist/index.d.ts.map":{"size":317,"mtime_nanos":1777698592015999750,"mode":420,"is_dir":false},"packages/shared-billing/dist/middleware/billing.middleware.js.map":{"size":3848,"mtime_nanos":1777698592006999552,"mode":420,"is_dir":false},"packages/shared-billing/dist/services":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/shared-billing/dist/config/billing.config.js.map":{"size":3157,"mtime_nanos":1777698591945998218,"mode":420,"is_dir":false}},"order":["packages/shared-billing/.turbo/turbo-build.log","packages/shared-billing/dist","packages/shared-billing/dist/config","packages/shared-billing/dist/config/billing.config.d.ts","packages/shared-billing/dist/config/billing.config.d.ts.map","packages/shared-billing/dist/config/billing.config.js","packages/shared-billing/dist/config/billing.config.js.map","packages/shared-billing/dist/index.d.ts","packages/shared-billing/dist/index.d.ts.map","packages/shared-billing/dist/index.js","packages/shared-billing/dist/index.js.map","packages/shared-billing/dist/middleware","packages/shared-billing/dist/middleware/billing.middleware.d.ts","packages/shared-billing/dist/middleware/billing.middleware.d.ts.map","packages/shared-billing/dist/middleware/billing.middleware.js","packages/shared-billing/dist/middleware/billing.middleware.js.map","packages/shared-billing/dist/models","packages/shared-billing/dist/models/subscription.model.d.ts","packages/shared-billing/dist/models/subscription.model.d.ts.map","packages/shared-billing/dist/models/subscription.model.js","packages/shared-billing/dist/models/subscription.model.js.map","packages/shared-billing/dist/services","packages/shared-billing/dist/services/billing.service.d.ts","packages/shared-billing/dist/services/billing.service.d.ts.map","packages/shared-billing/dist/services/billing.service.js","packages/shared-billing/dist/services/billing.service.js.map"]}
|
||||
1
.turbo/cache/f810866ff5911e6a-meta.json
vendored
1
.turbo/cache/f810866ff5911e6a-meta.json
vendored
@@ -1 +0,0 @@
|
||||
{"hash":"f810866ff5911e6a","duration":1541,"sha":"685fb57e53b5d01707795f6ec6f119356e0bfd12","dirty_hash":"0908f7ed09b46b26ba2dfc1c94e994cefe9e2f178fad10e9c8483f8ee168d061"}
|
||||
BIN
.turbo/cache/f810866ff5911e6a.tar.zst
vendored
BIN
.turbo/cache/f810866ff5911e6a.tar.zst
vendored
Binary file not shown.
38
Dockerfile
38
Dockerfile
@@ -1,38 +0,0 @@
|
||||
# Build stage
|
||||
FROM node:18-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
COPY apps/ ./apps/
|
||||
COPY packages/ ./packages/
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Build all packages
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM node:18-alpine AS production
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
COPY apps/ ./apps/
|
||||
COPY packages/ ./packages/
|
||||
|
||||
# Copy built artifacts from builder
|
||||
COPY --from=builder /app/apps/web/dist ./apps/web/dist
|
||||
COPY --from=builder /app/apps/api/dist ./apps/api/dist
|
||||
|
||||
# Install production dependencies only
|
||||
RUN npm ci --production
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
# Start the API server
|
||||
CMD ["node", "apps/api/dist/index.js"]
|
||||
15
android/ShieldAI/.gitignore
vendored
Normal file
15
android/ShieldAI/.gitignore
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea/caches
|
||||
/.idea/libraries
|
||||
/.idea/modules.xml
|
||||
/.idea/workspace.xml
|
||||
/.idea/navEditor.xml
|
||||
/.idea/assetWizardSettings.xml
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
3
android/ShieldAI/.idea/.gitignore
generated
vendored
Normal file
3
android/ShieldAI/.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
1
android/ShieldAI/app/.gitignore
vendored
Normal file
1
android/ShieldAI/app/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
92
android/ShieldAI/app/build.gradle.kts
Normal file
92
android/ShieldAI/app/build.gradle.kts
Normal file
@@ -0,0 +1,92 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.shieldai.android"
|
||||
compileSdk {
|
||||
version = release(36) {
|
||||
minorApiLevel = 1
|
||||
}
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.shieldai.android"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
buildConfigField("String", "API_BASE_URL", "\"http://10.0.2.2:3000\"")
|
||||
buildConfigField("String", "API_STAGING_URL", "\"https://staging.api.shieldai.com\"")
|
||||
buildConfigField("String", "API_PRODUCTION_URL", "\"https://api.shieldai.com\"")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
buildConfigField("String", "API_BASE_URL", "\"http://10.0.2.2:3000\"")
|
||||
}
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
buildConfigField("String", "API_BASE_URL", "\"https://api.shieldai.com\"")
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
lint {
|
||||
baseline = file("lint-baseline.xml")
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.ui.graphics)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.compose.material3.adaptive.navigation.suite)
|
||||
implementation("androidx.compose.material:material-icons-core")
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.lottie.compose)
|
||||
implementation(libs.androidx.security.crypto)
|
||||
implementation(libs.androidx.biometric)
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.okhttp.logging.interceptor)
|
||||
implementation(libs.gson)
|
||||
implementation(libs.play.services.auth)
|
||||
implementation(libs.retrofit)
|
||||
implementation(libs.retrofit.kotlinx.serialization.converter)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.work.runtime.ktx)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(libs.truth)
|
||||
testImplementation(libs.okhttp.mockwebserver)
|
||||
testImplementation(libs.work.testing)
|
||||
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
}
|
||||
521
android/ShieldAI/app/lint-baseline.xml
Normal file
521
android/ShieldAI/app/lint-baseline.xml
Normal file
@@ -0,0 +1,521 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<issues format="6" by="lint 9.1.1" type="baseline" client="gradle" dependencies="false" name="AGP (9.1.1)" variant="all" version="9.1.1">
|
||||
|
||||
<issue
|
||||
id="RedundantLabel"
|
||||
message="Redundant label can be removed"
|
||||
errorLine1=" android:label="@string/app_name""
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/AndroidManifest.xml"
|
||||
line="20"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="AndroidGradlePluginVersion"
|
||||
message="A newer version of Gradle than 9.3.1 is available: 9.5.1"
|
||||
errorLine1="distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/wrapper/gradle-wrapper.properties"
|
||||
line="5"
|
||||
column="17"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="AndroidGradlePluginVersion"
|
||||
message="A newer version of com.android.application than 9.1.1 is available: 9.2.1"
|
||||
errorLine1="agp = "9.1.1""
|
||||
errorLine2=" ~~~~~~~">
|
||||
<location
|
||||
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
|
||||
line="2"
|
||||
column="7"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="GradleDependency"
|
||||
message="A newer version of androidx.core:core-ktx than 1.10.1 is available: 1.18.0"
|
||||
errorLine1="coreKtx = "1.10.1""
|
||||
errorLine2=" ~~~~~~~~">
|
||||
<location
|
||||
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
|
||||
line="3"
|
||||
column="11"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="GradleDependency"
|
||||
message="A newer version of androidx.test.ext:junit than 1.1.5 is available: 1.3.0"
|
||||
errorLine1="junitVersion = "1.1.5""
|
||||
errorLine2=" ~~~~~~~">
|
||||
<location
|
||||
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
|
||||
line="5"
|
||||
column="16"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="GradleDependency"
|
||||
message="A newer version of androidx.test.espresso:espresso-core than 3.5.1 is available: 3.7.0"
|
||||
errorLine1="espressoCore = "3.5.1""
|
||||
errorLine2=" ~~~~~~~">
|
||||
<location
|
||||
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
|
||||
line="6"
|
||||
column="16"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="GradleDependency"
|
||||
message="A newer version of androidx.lifecycle:lifecycle-runtime-ktx than 2.6.1 is available: 2.10.0"
|
||||
errorLine1="lifecycleRuntimeKtx = "2.6.1""
|
||||
errorLine2=" ~~~~~~~">
|
||||
<location
|
||||
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
|
||||
line="7"
|
||||
column="23"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="GradleDependency"
|
||||
message="A newer version of androidx.activity:activity-compose than 1.8.0 is available: 1.13.0"
|
||||
errorLine1="activityCompose = "1.8.0""
|
||||
errorLine2=" ~~~~~~~">
|
||||
<location
|
||||
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
|
||||
line="8"
|
||||
column="19"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="GradleDependency"
|
||||
message="A newer version of androidx.navigation:navigation-compose than 2.7.7 is available: 2.9.8"
|
||||
errorLine1="navigationCompose = "2.7.7""
|
||||
errorLine2=" ~~~~~~~">
|
||||
<location
|
||||
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
|
||||
line="9"
|
||||
column="21"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="GradleDependency"
|
||||
message="A newer version of androidx.compose:compose-bom than 2025.12.00 is available: 2026.05.01"
|
||||
errorLine1="composeBom = "2025.12.00""
|
||||
errorLine2=" ~~~~~~~~~~~~">
|
||||
<location
|
||||
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
|
||||
line="11"
|
||||
column="14"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="GradleDependency"
|
||||
message="A newer version of androidx.security:security-crypto than 1.1.0-alpha06 is available: 1.1.0"
|
||||
errorLine1="securityCrypto = "1.1.0-alpha06""
|
||||
errorLine2=" ~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
|
||||
line="13"
|
||||
column="18"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="GradleDependency"
|
||||
message="A newer version of com.google.android.gms:play-services-auth than 21.0.0 is available: 21.5.1"
|
||||
errorLine1="playServicesAuth = "21.0.0""
|
||||
errorLine2=" ~~~~~~~~">
|
||||
<location
|
||||
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
|
||||
line="15"
|
||||
column="20"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="GradleDependency"
|
||||
message="A newer version of androidx.work:work-runtime-ktx than 2.9.1 is available: 2.11.2"
|
||||
errorLine1="work = "2.9.1""
|
||||
errorLine2=" ~~~~~~~">
|
||||
<location
|
||||
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
|
||||
line="23"
|
||||
column="8"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="GradleDependency"
|
||||
message="A newer version of androidx.work:work-testing than 2.9.1 is available: 2.11.2"
|
||||
errorLine1="work = "2.9.1""
|
||||
errorLine2=" ~~~~~~~">
|
||||
<location
|
||||
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
|
||||
line="23"
|
||||
column="8"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="NewerVersionAvailable"
|
||||
message="A newer version of org.jetbrains.kotlin.plugin.compose than 2.2.10 is available: 2.3.21"
|
||||
errorLine1="kotlin = "2.2.10""
|
||||
errorLine2=" ~~~~~~~~">
|
||||
<location
|
||||
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
|
||||
line="10"
|
||||
column="10"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="NewerVersionAvailable"
|
||||
message="A newer version of org.jetbrains.kotlin.plugin.serialization than 2.2.10 is available: 2.3.21"
|
||||
errorLine1="kotlin = "2.2.10""
|
||||
errorLine2=" ~~~~~~~~">
|
||||
<location
|
||||
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
|
||||
line="10"
|
||||
column="10"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="NewerVersionAvailable"
|
||||
message="A newer version of com.squareup.okhttp3:logging-interceptor than 4.12.0 is available: 5.3.2"
|
||||
errorLine1="okhttp = "4.12.0""
|
||||
errorLine2=" ~~~~~~~~">
|
||||
<location
|
||||
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
|
||||
line="16"
|
||||
column="10"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="NewerVersionAvailable"
|
||||
message="A newer version of com.squareup.okhttp3:okhttp than 4.12.0 is available: 5.3.2"
|
||||
errorLine1="okhttp = "4.12.0""
|
||||
errorLine2=" ~~~~~~~~">
|
||||
<location
|
||||
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
|
||||
line="16"
|
||||
column="10"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="NewerVersionAvailable"
|
||||
message="A newer version of com.google.code.gson:gson than 2.10.1 is available: 2.14.0"
|
||||
errorLine1="gson = "2.10.1""
|
||||
errorLine2=" ~~~~~~~~">
|
||||
<location
|
||||
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
|
||||
line="17"
|
||||
column="8"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="NewerVersionAvailable"
|
||||
message="A newer version of com.airbnb.android:lottie-compose than 6.4.0 is available: 6.7.1"
|
||||
errorLine1="lottieCompose = "6.4.0""
|
||||
errorLine2=" ~~~~~~~">
|
||||
<location
|
||||
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
|
||||
line="18"
|
||||
column="17"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="NewerVersionAvailable"
|
||||
message="A newer version of org.jetbrains.kotlinx:kotlinx-coroutines-test than 1.7.3 is available: 1.11.0"
|
||||
errorLine1="coroutinesTest = "1.7.3""
|
||||
errorLine2=" ~~~~~~~">
|
||||
<location
|
||||
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
|
||||
line="19"
|
||||
column="18"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="NewerVersionAvailable"
|
||||
message="A newer version of com.squareup.retrofit2:retrofit than 2.11.0 is available: 3.0.0"
|
||||
errorLine1="retrofit = "2.11.0""
|
||||
errorLine2=" ~~~~~~~~">
|
||||
<location
|
||||
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
|
||||
line="20"
|
||||
column="12"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="NewerVersionAvailable"
|
||||
message="A newer version of org.jetbrains.kotlinx:kotlinx-serialization-json than 1.7.3 is available: 1.11.0"
|
||||
errorLine1="kotlinxSerializationJson = "1.7.3""
|
||||
errorLine2=" ~~~~~~~">
|
||||
<location
|
||||
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
|
||||
line="22"
|
||||
column="28"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="NewerVersionAvailable"
|
||||
message="A newer version of com.google.truth:truth than 1.4.4 is available: 1.4.5"
|
||||
errorLine1="truth = "1.4.4""
|
||||
errorLine2=" ~~~~~~~">
|
||||
<location
|
||||
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
|
||||
line="24"
|
||||
column="9"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="NewerVersionAvailable"
|
||||
message="A newer version of com.squareup.okhttp3:mockwebserver than 4.12.0 is available: 5.3.2"
|
||||
errorLine1="mockwebserver = "4.12.0""
|
||||
errorLine2=" ~~~~~~~~">
|
||||
<location
|
||||
file="$HOME/Code/ShieldAI/android/ShieldAI/gradle/libs.versions.toml"
|
||||
line="25"
|
||||
column="17"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="LocalContextGetResourceValueCall"
|
||||
message="Querying resource values using LocalContext.current"
|
||||
errorLine1=" .requestIdToken(context.getString(com.shieldai.android.R.string.default_web_client_id))"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/java/com/shieldai/android/ui/screens/auth/LoginScreen.kt"
|
||||
line="56"
|
||||
column="29"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="StaticFieldLeak"
|
||||
message="Do not place Android context classes in static fields (static reference to `UserRepository` which has field `context` pointing to `Context`); this is a memory leak"
|
||||
errorLine1=" private var userRepository: UserRepository? = null"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/java/com/shieldai/android/di/RepositoryModule.kt"
|
||||
line="11"
|
||||
column="5"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="StaticFieldLeak"
|
||||
message="Do not place Android context classes in static fields (static reference to `DarkWatchRepository` which has field `context` pointing to `Context`); this is a memory leak"
|
||||
errorLine1=" private var darkWatchRepository: DarkWatchRepository? = null"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/java/com/shieldai/android/di/RepositoryModule.kt"
|
||||
line="12"
|
||||
column="5"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="StaticFieldLeak"
|
||||
message="Do not place Android context classes in static fields (static reference to `VoicePrintRepository` which has field `context` pointing to `Context`); this is a memory leak"
|
||||
errorLine1=" private var voicePrintRepository: VoicePrintRepository? = null"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/java/com/shieldai/android/di/RepositoryModule.kt"
|
||||
line="13"
|
||||
column="5"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="StaticFieldLeak"
|
||||
message="Do not place Android context classes in static fields (static reference to `AlertRepository` which has field `context` pointing to `Context`); this is a memory leak"
|
||||
errorLine1=" private var alertRepository: AlertRepository? = null"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/java/com/shieldai/android/di/RepositoryModule.kt"
|
||||
line="14"
|
||||
column="5"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="StaticFieldLeak"
|
||||
message="Do not place Android context classes in static fields (static reference to `SubscriptionRepository` which has field `context` pointing to `Context`); this is a memory leak"
|
||||
errorLine1=" private var subscriptionRepository: SubscriptionRepository? = null"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/java/com/shieldai/android/di/RepositoryModule.kt"
|
||||
line="15"
|
||||
column="5"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedResources"
|
||||
message="The resource `R.color.brand_primary` appears to be unused"
|
||||
errorLine1=" <color name="brand_primary">#FF4F46E5</color>"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/colors.xml"
|
||||
line="3"
|
||||
column="12"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedResources"
|
||||
message="The resource `R.color.brand_primary_light` appears to be unused"
|
||||
errorLine1=" <color name="brand_primary_light">#FF818CF8</color>"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/colors.xml"
|
||||
line="4"
|
||||
column="12"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedResources"
|
||||
message="The resource `R.color.brand_accent` appears to be unused"
|
||||
errorLine1=" <color name="brand_accent">#FF06B6D4</color>"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/colors.xml"
|
||||
line="5"
|
||||
column="12"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedResources"
|
||||
message="The resource `R.color.bg_primary` appears to be unused"
|
||||
errorLine1=" <color name="bg_primary">#FFFFFFFF</color>"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/colors.xml"
|
||||
line="6"
|
||||
column="12"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedResources"
|
||||
message="The resource `R.color.bg_primary_dark` appears to be unused"
|
||||
errorLine1=" <color name="bg_primary_dark">#FF0F172A</color>"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/colors.xml"
|
||||
line="7"
|
||||
column="12"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedResources"
|
||||
message="The resource `R.color.text_primary` appears to be unused"
|
||||
errorLine1=" <color name="text_primary">#FF0F172A</color>"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/colors.xml"
|
||||
line="8"
|
||||
column="12"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedResources"
|
||||
message="The resource `R.color.text_primary_dark` appears to be unused"
|
||||
errorLine1=" <color name="text_primary_dark">#FFF1F5F9</color>"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/colors.xml"
|
||||
line="9"
|
||||
column="12"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedResources"
|
||||
message="The resource `R.color.success` appears to be unused"
|
||||
errorLine1=" <color name="success">#FF22C55E</color>"
|
||||
errorLine2=" ~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/colors.xml"
|
||||
line="10"
|
||||
column="12"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedResources"
|
||||
message="The resource `R.color.warning` appears to be unused"
|
||||
errorLine1=" <color name="warning">#FFF59E0B</color>"
|
||||
errorLine2=" ~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/colors.xml"
|
||||
line="11"
|
||||
column="12"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedResources"
|
||||
message="The resource `R.color.error` appears to be unused"
|
||||
errorLine1=" <color name="error">#FFEF4444</color>"
|
||||
errorLine2=" ~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/colors.xml"
|
||||
line="12"
|
||||
column="12"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedResources"
|
||||
message="The resource `R.color.info` appears to be unused"
|
||||
errorLine1=" <color name="info">#FF3B82F6</color>"
|
||||
errorLine2=" ~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/colors.xml"
|
||||
line="13"
|
||||
column="12"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedResources"
|
||||
message="The resource `R.drawable.ic_home` appears to be unused"
|
||||
errorLine1="<vector xmlns:android="http://schemas.android.com/apk/res/android""
|
||||
errorLine2="^">
|
||||
<location
|
||||
file="src/main/res/drawable/ic_home.xml"
|
||||
line="1"
|
||||
column="1"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UseKtx"
|
||||
message="Use the KTX extension function `SharedPreferences.edit` instead?"
|
||||
errorLine1=" securePrefs.edit()"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/java/com/shieldai/android/data/repository/AuthRepository.kt"
|
||||
line="144"
|
||||
column="9"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UseKtx"
|
||||
message="Use the KTX extension function `SharedPreferences.edit` instead?"
|
||||
errorLine1=" securePrefs.edit()"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/java/com/shieldai/android/data/repository/AuthRepository.kt"
|
||||
line="155"
|
||||
column="9"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UseKtx"
|
||||
message="Use the KTX extension function `SharedPreferences.edit` instead?"
|
||||
errorLine1=" prefs.edit().putBoolean("biometric_enabled", enabled).apply()"
|
||||
errorLine2=" ~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/java/com/shieldai/android/ui/screens/auth/BiometricAuthScreen.kt"
|
||||
line="88"
|
||||
column="5"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UseTomlInstead"
|
||||
message="Use version catalog instead"
|
||||
errorLine1=" implementation("androidx.compose.material:material-icons-core")"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="build.gradle.kts"
|
||||
line="66"
|
||||
column="20"/>
|
||||
</issue>
|
||||
|
||||
</issues>
|
||||
21
android/ShieldAI/app/proguard-rules.pro
vendored
Normal file
21
android/ShieldAI/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
@@ -0,0 +1,220 @@
|
||||
package com.shieldai.android
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertTextContains
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTextClearance
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import com.shieldai.android.ui.components.BadgeVariant
|
||||
import com.shieldai.android.ui.components.ComponentShowcase
|
||||
import com.shieldai.android.ui.components.InputType
|
||||
import com.shieldai.android.ui.components.ShieldBadge
|
||||
import com.shieldai.android.ui.components.ShieldButton
|
||||
import com.shieldai.android.ui.components.ShieldButtonSize
|
||||
import com.shieldai.android.ui.components.ShieldButtonVariant
|
||||
import com.shieldai.android.ui.components.ShieldTextField
|
||||
import com.shieldai.android.ui.theme.ShieldAITheme
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class ComponentTests {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun shieldButton_rendersWithText() {
|
||||
composeTestRule.setContent {
|
||||
ShieldAITheme {
|
||||
ShieldButton(text = "Click Me", onClick = {})
|
||||
}
|
||||
}
|
||||
composeTestRule.onNodeWithText("Click Me").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shieldButton_clickHandlerFires() {
|
||||
var clicked = false
|
||||
composeTestRule.setContent {
|
||||
ShieldAITheme {
|
||||
ShieldButton(text = "Click Me", onClick = { clicked = true })
|
||||
}
|
||||
}
|
||||
composeTestRule.onNodeWithText("Click Me").performClick()
|
||||
assert(clicked) { "Button click handler was not invoked" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shieldButton_disabledDoesNotFireClick() {
|
||||
var clicked = false
|
||||
composeTestRule.setContent {
|
||||
ShieldAITheme {
|
||||
ShieldButton(text = "Click Me", onClick = { clicked = true }, enabled = false)
|
||||
}
|
||||
}
|
||||
composeTestRule.onNodeWithText("Click Me").performClick()
|
||||
assert(!clicked) { "Disabled button should not fire click handler" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shieldButton_showsLoadingIndicator() {
|
||||
composeTestRule.setContent {
|
||||
ShieldAITheme {
|
||||
ShieldButton(text = "Saving", onClick = {}, loading = true)
|
||||
}
|
||||
}
|
||||
composeTestRule.onNodeWithTag("CircularProgressIndicator").assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shieldButton_variantsRender() {
|
||||
composeTestRule.setContent {
|
||||
ShieldAITheme {
|
||||
ShieldButton(text = "Primary", onClick = {}, variant = ShieldButtonVariant.Primary)
|
||||
ShieldButton(text = "Secondary", onClick = {}, variant = ShieldButtonVariant.Secondary)
|
||||
ShieldButton(text = "Ghost", onClick = {}, variant = ShieldButtonVariant.Ghost)
|
||||
ShieldButton(text = "Danger", onClick = {}, variant = ShieldButtonVariant.Danger)
|
||||
}
|
||||
}
|
||||
composeTestRule.onNodeWithText("Primary").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Secondary").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Ghost").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Danger").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shieldButton_sizesRender() {
|
||||
composeTestRule.setContent {
|
||||
ShieldAITheme {
|
||||
ShieldButton(text = "Small", onClick = {}, size = ShieldButtonSize.Small)
|
||||
ShieldButton(text = "Medium", onClick = {}, size = ShieldButtonSize.Medium)
|
||||
ShieldButton(text = "Large", onClick = {}, size = ShieldButtonSize.Large)
|
||||
}
|
||||
}
|
||||
composeTestRule.onNodeWithText("Small").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Medium").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Large").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shieldButton_fullWidthRenders() {
|
||||
composeTestRule.setContent {
|
||||
ShieldAITheme {
|
||||
ShieldButton(text = "Full Width", onClick = {}, fullWidth = true, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
composeTestRule.onNodeWithText("Full Width").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shieldTextField_rendersWithLabel() {
|
||||
composeTestRule.setContent {
|
||||
ShieldAITheme {
|
||||
ShieldTextField(value = "", onValueChange = {}, label = "Email")
|
||||
}
|
||||
}
|
||||
composeTestRule.onNodeWithText("Email").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shieldTextField_showsErrorState() {
|
||||
composeTestRule.setContent {
|
||||
ShieldAITheme {
|
||||
ShieldTextField(
|
||||
value = "bad",
|
||||
onValueChange = {},
|
||||
label = "Input",
|
||||
isError = true,
|
||||
errorMessage = "Invalid input"
|
||||
)
|
||||
}
|
||||
}
|
||||
composeTestRule.onNodeWithText("Invalid input").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shieldTextField_helperTextDisplayed() {
|
||||
composeTestRule.setContent {
|
||||
ShieldAITheme {
|
||||
ShieldTextField(
|
||||
value = "",
|
||||
onValueChange = {},
|
||||
label = "Input",
|
||||
helperText = "Enter your name"
|
||||
)
|
||||
}
|
||||
}
|
||||
composeTestRule.onNodeWithText("Enter your name").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shieldTextField_passwordToggleExists() {
|
||||
composeTestRule.setContent {
|
||||
ShieldAITheme {
|
||||
ShieldTextField(
|
||||
value = "",
|
||||
onValueChange = {},
|
||||
label = "Password",
|
||||
inputType = InputType.Password
|
||||
)
|
||||
}
|
||||
}
|
||||
composeTestRule.onNodeWithText("Show").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shieldBadge_variantsRender() {
|
||||
composeTestRule.setContent {
|
||||
ShieldAITheme {
|
||||
ShieldBadge(text = "Success", variant = BadgeVariant.Success)
|
||||
ShieldBadge(text = "Error", variant = BadgeVariant.Error)
|
||||
ShieldBadge(text = "Warning", variant = BadgeVariant.Warning)
|
||||
ShieldBadge(text = "Info", variant = BadgeVariant.Info)
|
||||
ShieldBadge(text = "Default", variant = BadgeVariant.Default)
|
||||
}
|
||||
}
|
||||
composeTestRule.onNodeWithText("Success").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Error").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Warning").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Info").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Default").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shieldTextField_acceptsInput() {
|
||||
composeTestRule.setContent {
|
||||
ShieldAITheme {
|
||||
ShieldTextField(
|
||||
value = "",
|
||||
onValueChange = {},
|
||||
label = "Name"
|
||||
)
|
||||
}
|
||||
}
|
||||
composeTestRule.onNodeWithText("Name").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun componentShowcase_renders() {
|
||||
composeTestRule.setContent {
|
||||
ShieldAITheme {
|
||||
ComponentShowcase()
|
||||
}
|
||||
}
|
||||
composeTestRule.onNodeWithText("ShieldAI Design System").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("ShieldButton").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("ShieldCard").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("ShieldBadge").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("ShieldAvatar").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("ShieldProgressBar").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("ShieldEmptyState").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("ShieldSkeleton").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("ShieldToast").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("ShieldModal").assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.shieldai.android
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExampleInstrumentedTest {
|
||||
@Test
|
||||
fun useAppContext() {
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("com.shieldai.android", appContext.packageName)
|
||||
}
|
||||
}
|
||||
29
android/ShieldAI/app/src/main/AndroidManifest.xml
Normal file
29
android/ShieldAI/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:name=".ShieldAIApp"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.ShieldAI">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/Theme.ShieldAI">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.shieldai.android
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import com.shieldai.android.navigation.AppNavigation
|
||||
import com.shieldai.android.ui.theme.ShieldAITheme
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
ShieldAITheme {
|
||||
AppNavigation()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.shieldai.android
|
||||
|
||||
import android.app.Application
|
||||
import com.shieldai.android.data.repository.AuthRepository
|
||||
import com.shieldai.android.data.repository.AuthRepositoryImpl
|
||||
|
||||
class ShieldAIApp : Application() {
|
||||
lateinit var authRepository: AuthRepository
|
||||
private set
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
authRepository = AuthRepositoryImpl(this)
|
||||
}
|
||||
|
||||
companion object {
|
||||
lateinit var instance: ShieldAIApp
|
||||
private set
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.shieldai.android.data.local
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
|
||||
@Serializable
|
||||
data class CacheEntry<T>(
|
||||
val data: T,
|
||||
val cachedAt: Long = System.currentTimeMillis(),
|
||||
val ttlMs: Long = CacheManager.DEFAULT_TTL_MS,
|
||||
) {
|
||||
fun isExpired(): Boolean = System.currentTimeMillis() - cachedAt > ttlMs
|
||||
}
|
||||
|
||||
object CacheManager {
|
||||
const val DEFAULT_TTL_MS = 5 * 60 * 1000L
|
||||
private val ttlOverrides = mutableMapOf<String, Long>()
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
fun setTtl(tableName: String, ttlMs: Long) {
|
||||
ttlOverrides[tableName] = ttlMs
|
||||
}
|
||||
|
||||
fun getTtl(tableName: String): Long = ttlOverrides[tableName] ?: DEFAULT_TTL_MS
|
||||
|
||||
fun <T> save(context: Context, key: String, data: T) {
|
||||
val entry = CacheEntry(
|
||||
data = data,
|
||||
cachedAt = System.currentTimeMillis(),
|
||||
ttlMs = getTtl(key),
|
||||
)
|
||||
val file = File(context.cacheDir, "$key.cache")
|
||||
file.writeText(json.encodeToString(entry))
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun <T> load(context: Context, key: String): T? {
|
||||
val file = File(context.cacheDir, "$key.cache")
|
||||
if (!file.exists()) return null
|
||||
return try {
|
||||
val text = file.readText()
|
||||
val entry = json.decodeFromString<CacheEntry<Map<String, Any>>>(text)
|
||||
if (entry.isExpired()) {
|
||||
file.delete()
|
||||
null
|
||||
} else {
|
||||
json.decodeFromString<CacheEntry<T>>(text).data
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
file.delete()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun clear(context: Context, key: String) {
|
||||
File(context.cacheDir, "$key.cache").delete()
|
||||
}
|
||||
|
||||
fun clearAll(context: Context) {
|
||||
context.cacheDir.listFiles { _, name -> name.endsWith(".cache") }?.forEach { it.delete() }
|
||||
}
|
||||
|
||||
fun isExpired(cachedAt: Long, tableName: String): Boolean {
|
||||
val ttl = getTtl(tableName)
|
||||
return System.currentTimeMillis() - cachedAt > ttl
|
||||
}
|
||||
|
||||
fun isFresh(cachedAt: Long, tableName: String): Boolean = !isExpired(cachedAt, tableName)
|
||||
|
||||
fun clearOverrides() = ttlOverrides.clear()
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.shieldai.android.data.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Alert(
|
||||
val id: String,
|
||||
val type: String,
|
||||
val title: String,
|
||||
val message: String,
|
||||
val severity: String,
|
||||
val read: Boolean = false,
|
||||
val date: String? = null,
|
||||
@SerialName("action_url") val actionUrl: String? = null,
|
||||
@SerialName("created_at") val createdAt: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.shieldai.android.data.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class BrokerListing(
|
||||
val id: String,
|
||||
@SerialName("broker_name") val brokerName: String,
|
||||
@SerialName("property_address") val propertyAddress: String? = null,
|
||||
val url: String? = null,
|
||||
val status: String = "active",
|
||||
@SerialName("date_found") val dateFound: String? = null,
|
||||
@SerialName("removal_request_id") val removalRequestId: String? = null,
|
||||
@SerialName("created_at") val createdAt: String? = null,
|
||||
@SerialName("updated_at") val updatedAt: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.shieldai.android.data.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Exposure(
|
||||
val id: String,
|
||||
val type: String,
|
||||
val source: String,
|
||||
val severity: String,
|
||||
val details: String? = null,
|
||||
val date: String? = null,
|
||||
@SerialName("watchlist_item_id") val watchlistItemId: String? = null,
|
||||
val resolved: Boolean = false,
|
||||
@SerialName("resolved_at") val resolvedAt: String? = null,
|
||||
@SerialName("created_at") val createdAt: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.shieldai.android.data.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Property(
|
||||
val id: String,
|
||||
val address: String,
|
||||
val type: String,
|
||||
@SerialName("owner_name") val ownerName: String? = null,
|
||||
val county: String? = null,
|
||||
@SerialName("document_id") val documentId: String? = null,
|
||||
val status: String = "monitored",
|
||||
@SerialName("created_at") val createdAt: String? = null,
|
||||
@SerialName("updated_at") val updatedAt: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.shieldai.android.data.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class RemovalRequest(
|
||||
val id: String,
|
||||
@SerialName("listing_id") val listingId: String,
|
||||
val status: String,
|
||||
@SerialName("submitted_date") val submittedDate: String? = null,
|
||||
@SerialName("resolved_date") val resolvedDate: String? = null,
|
||||
val notes: String? = null,
|
||||
@SerialName("created_at") val createdAt: String? = null,
|
||||
@SerialName("updated_at") val updatedAt: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.shieldai.android.data.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class SpamRule(
|
||||
val id: String,
|
||||
val pattern: String,
|
||||
val action: String,
|
||||
val enabled: Boolean = true,
|
||||
val description: String? = null,
|
||||
val priority: Int = 0,
|
||||
@SerialName("created_at") val createdAt: String? = null,
|
||||
@SerialName("updated_at") val updatedAt: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.shieldai.android.data.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Subscription(
|
||||
val id: String,
|
||||
val plan: String,
|
||||
val status: String,
|
||||
@SerialName("start_date") val startDate: String? = null,
|
||||
@SerialName("end_date") val endDate: String? = null,
|
||||
val features: List<String> = emptyList(),
|
||||
@SerialName("auto_renew") val autoRenew: Boolean = true,
|
||||
@SerialName("created_at") val createdAt: String? = null,
|
||||
@SerialName("updated_at") val updatedAt: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.shieldai.android.data.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class User(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val email: String,
|
||||
val phone: String? = null,
|
||||
@SerialName("avatar_url") val avatarUrl: String? = null,
|
||||
@SerialName("subscription_tier") val subscriptionTier: String? = null,
|
||||
@SerialName("email_verified") val emailVerified: Boolean = false,
|
||||
@SerialName("phone_verified") val phoneVerified: Boolean = false,
|
||||
@SerialName("is_new_user") val isNewUser: Boolean = false,
|
||||
@SerialName("created_at") val createdAt: String? = null,
|
||||
@SerialName("updated_at") val updatedAt: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.shieldai.android.data.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class VoiceAnalysis(
|
||||
val id: String,
|
||||
@SerialName("enrollment_id") val enrollmentId: String,
|
||||
val confidence: Double = 0.0,
|
||||
val result: String? = null,
|
||||
val status: String = "pending",
|
||||
@SerialName("created_at") val createdAt: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.shieldai.android.data.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class VoiceEnrollment(
|
||||
val id: String,
|
||||
val name: String,
|
||||
@SerialName("sample_count") val sampleCount: Int = 0,
|
||||
val status: String = "pending",
|
||||
@SerialName("created_at") val createdAt: String? = null,
|
||||
@SerialName("updated_at") val updatedAt: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.shieldai.android.data.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class WatchlistItem(
|
||||
val id: String,
|
||||
val type: String,
|
||||
val value: String,
|
||||
val label: String? = null,
|
||||
val status: String = "active",
|
||||
@SerialName("date_added") val dateAdded: String? = null,
|
||||
@SerialName("last_checked") val lastChecked: String? = null,
|
||||
@SerialName("alerts_enabled") val alertsEnabled: Boolean = true,
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.shieldai.android.data.remote
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
|
||||
class AuthInterceptor(context: Context) : Interceptor {
|
||||
|
||||
private val securePrefs: SharedPreferences = EncryptedSharedPreferences.create(
|
||||
context,
|
||||
"shieldai_auth_prefs",
|
||||
MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(),
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val token = securePrefs.getString("access_token", null)
|
||||
val request = if (token != null) {
|
||||
chain.request().newBuilder()
|
||||
.addHeader("Authorization", "Bearer $token")
|
||||
.build()
|
||||
} else {
|
||||
chain.request()
|
||||
}
|
||||
return chain.proceed(request)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.shieldai.android.data.remote
|
||||
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.math.min
|
||||
import kotlin.math.pow
|
||||
|
||||
sealed class ApiResult<out T> {
|
||||
data class Success<T>(val data: T) : ApiResult<T>()
|
||||
data class Error(val message: String, val code: Int = -1) : ApiResult<Nothing>()
|
||||
}
|
||||
|
||||
object ErrorHandler {
|
||||
private const val MAX_RETRIES = 3
|
||||
private const val BASE_DELAY_MS = 1000L
|
||||
private const val MAX_DELAY_MS = 10000L
|
||||
|
||||
suspend fun <T> executeWithRetry(
|
||||
maxRetries: Int = MAX_RETRIES,
|
||||
block: suspend () -> T,
|
||||
): ApiResult<T> {
|
||||
var lastError: Exception? = null
|
||||
for (attempt in 0..maxRetries) {
|
||||
try {
|
||||
val result = block()
|
||||
return ApiResult.Success(result)
|
||||
} catch (e: Exception) {
|
||||
lastError = e
|
||||
if (attempt < maxRetries && shouldRetry(e)) {
|
||||
val delayMs = calculateBackoff(attempt)
|
||||
delay(delayMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ApiResult.Error(lastError?.message ?: "Unknown error")
|
||||
}
|
||||
|
||||
private fun shouldRetry(e: Exception): Boolean {
|
||||
return when {
|
||||
e is java.net.SocketTimeoutException -> true
|
||||
e is java.net.ConnectException -> true
|
||||
e is java.net.UnknownHostException -> true
|
||||
e is java.io.IOException -> true
|
||||
e.message?.contains("503") == true -> true
|
||||
e.message?.contains("429") == true -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculateBackoff(attempt: Int): Long {
|
||||
val exponential = BASE_DELAY_MS * 2.0.pow(attempt.toDouble())
|
||||
return min(exponential.toLong(), MAX_DELAY_MS)
|
||||
}
|
||||
|
||||
fun parseError(throwable: Throwable): String {
|
||||
return when (throwable) {
|
||||
is java.net.UnknownHostException -> "No internet connection"
|
||||
is java.net.SocketTimeoutException -> "Request timed out"
|
||||
is java.net.ConnectException -> "Connection refused"
|
||||
is java.io.IOException -> "Network error: ${throwable.message}"
|
||||
else -> throwable.message ?: "Unknown error"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.shieldai.android.data.remote
|
||||
|
||||
import com.shieldai.android.data.model.Alert
|
||||
import com.shieldai.android.data.model.BrokerListing
|
||||
import com.shieldai.android.data.model.Exposure
|
||||
import com.shieldai.android.data.model.Property
|
||||
import com.shieldai.android.data.model.RemovalRequest
|
||||
import com.shieldai.android.data.model.SpamRule
|
||||
import com.shieldai.android.data.model.Subscription
|
||||
import com.shieldai.android.data.model.User
|
||||
import com.shieldai.android.data.model.VoiceAnalysis
|
||||
import com.shieldai.android.data.model.VoiceEnrollment
|
||||
import com.shieldai.android.data.model.WatchlistItem
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.POST
|
||||
|
||||
interface TRPCApiService {
|
||||
@POST("api/trpc/user.me")
|
||||
suspend fun userMe(@Body body: JsonObject): TRPCResponse<User>
|
||||
|
||||
@POST("api/trpc/user.updateProfile")
|
||||
suspend fun userUpdateProfile(@Body body: JsonObject): TRPCResponse<User>
|
||||
|
||||
@POST("api/trpc/subscription.get")
|
||||
suspend fun subscriptionGet(@Body body: JsonObject): TRPCResponse<Subscription>
|
||||
|
||||
@POST("api/trpc/subscription.update")
|
||||
suspend fun subscriptionUpdate(@Body body: JsonObject): TRPCResponse<Subscription>
|
||||
|
||||
@POST("api/trpc/darkwatch.getWatchlist")
|
||||
suspend fun darkWatchGetWatchlist(@Body body: JsonObject): TRPCResponse<List<WatchlistItem>>
|
||||
|
||||
@POST("api/trpc/darkwatch.addWatchlistItem")
|
||||
suspend fun darkWatchAddWatchlistItem(@Body body: JsonObject): TRPCResponse<WatchlistItem>
|
||||
|
||||
@POST("api/trpc/darkwatch.removeWatchlistItem")
|
||||
suspend fun darkWatchRemoveWatchlistItem(@Body body: JsonObject): TRPCResponse<Unit>
|
||||
|
||||
@POST("api/trpc/darkwatch.getExposures")
|
||||
suspend fun darkWatchGetExposures(@Body body: JsonObject): TRPCResponse<List<Exposure>>
|
||||
|
||||
@POST("api/trpc/alerts.list")
|
||||
suspend fun alertsList(@Body body: JsonObject): TRPCResponse<List<Alert>>
|
||||
|
||||
@POST("api/trpc/alerts.markRead")
|
||||
suspend fun alertsMarkRead(@Body body: JsonObject): TRPCResponse<Alert>
|
||||
|
||||
@POST("api/trpc/voice.enrollments")
|
||||
suspend fun voiceEnrollments(@Body body: JsonObject): TRPCResponse<List<VoiceEnrollment>>
|
||||
|
||||
@POST("api/trpc/voice.createEnrollment")
|
||||
suspend fun voiceCreateEnrollment(@Body body: JsonObject): TRPCResponse<VoiceEnrollment>
|
||||
|
||||
@POST("api/trpc/voice.analyze")
|
||||
suspend fun voiceAnalyze(@Body body: JsonObject): TRPCResponse<VoiceAnalysis>
|
||||
|
||||
@POST("api/trpc/spam.listRules")
|
||||
suspend fun spamListRules(@Body body: JsonObject): TRPCResponse<List<SpamRule>>
|
||||
|
||||
@POST("api/trpc/spam.createRule")
|
||||
suspend fun spamCreateRule(@Body body: JsonObject): TRPCResponse<SpamRule>
|
||||
|
||||
@POST("api/trpc/property.list")
|
||||
suspend fun propertyList(@Body body: JsonObject): TRPCResponse<List<Property>>
|
||||
|
||||
@POST("api/trpc/property.add")
|
||||
suspend fun propertyAdd(@Body body: JsonObject): TRPCResponse<Property>
|
||||
|
||||
@POST("api/trpc/removal.list")
|
||||
suspend fun removalList(@Body body: JsonObject): TRPCResponse<List<RemovalRequest>>
|
||||
|
||||
@POST("api/trpc/removal.create")
|
||||
suspend fun removalCreate(@Body body: JsonObject): TRPCResponse<RemovalRequest>
|
||||
|
||||
@POST("api/trpc/broker.listListings")
|
||||
suspend fun brokerListListings(@Body body: JsonObject): TRPCResponse<List<BrokerListing>>
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.shieldai.android.data.remote
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
@Serializable
|
||||
data class TRPCResponse<T>(
|
||||
val result: TRPCResult<T>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TRPCResult<T>(
|
||||
val data: T,
|
||||
)
|
||||
|
||||
data class TRPCErrorResponse(
|
||||
val error: TRPCError,
|
||||
)
|
||||
|
||||
data class TRPCError(
|
||||
val message: String,
|
||||
val code: Int = -1,
|
||||
) {
|
||||
companion object {
|
||||
fun fromJson(json: JsonObject): TRPCError {
|
||||
val errorObj = json["error"]?.jsonObject
|
||||
val message = errorObj?.get("message")?.jsonPrimitive?.content ?: "Unknown error"
|
||||
val code = errorObj?.get("code")?.jsonPrimitive?.content?.toIntOrNull() ?: -1
|
||||
return TRPCError(message = message, code = code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object TRPCRequest {
|
||||
fun body(json: JsonObject): JsonObject {
|
||||
return buildJsonObject {
|
||||
put("0", buildJsonObject {
|
||||
put("json", json)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.shieldai.android.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import com.shieldai.android.data.local.CacheManager
|
||||
import com.shieldai.android.data.model.Alert
|
||||
import com.shieldai.android.data.remote.ApiResult
|
||||
import com.shieldai.android.data.remote.ErrorHandler
|
||||
import com.shieldai.android.data.remote.TRPCApiService
|
||||
import com.shieldai.android.data.remote.TRPCRequest
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
class AlertRepository(
|
||||
private val api: TRPCApiService,
|
||||
private val context: Context,
|
||||
) {
|
||||
private val _alerts = MutableStateFlow<List<Alert>>(emptyList())
|
||||
|
||||
suspend fun getAlerts(): ApiResult<List<Alert>> {
|
||||
val cached: List<Alert>? = CacheManager.load(context, "alerts")
|
||||
if (cached != null) {
|
||||
_alerts.value = cached
|
||||
return ApiResult.Success(cached)
|
||||
}
|
||||
return ErrorHandler.executeWithRetry {
|
||||
val response = api.alertsList(TRPCRequest.body(buildJsonObject {}))
|
||||
val alerts = response.result.data
|
||||
CacheManager.save(context, "alerts", alerts)
|
||||
_alerts.value = alerts
|
||||
alerts
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun markRead(id: String): ApiResult<Alert> {
|
||||
return ErrorHandler.executeWithRetry {
|
||||
val body = buildJsonObject { put("id", id) }
|
||||
val response = api.alertsMarkRead(TRPCRequest.body(body))
|
||||
val alert = response.result.data
|
||||
_alerts.value = _alerts.value.map { if (it.id == id) alert else it }
|
||||
alert
|
||||
}
|
||||
}
|
||||
|
||||
fun observeAlerts(): Flow<List<Alert>> = _alerts
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package com.shieldai.android.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONObject
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
data class AuthToken(
|
||||
val accessToken: String,
|
||||
val refreshToken: String? = null
|
||||
)
|
||||
|
||||
data class User(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val email: String,
|
||||
val isNewUser: Boolean = false
|
||||
)
|
||||
|
||||
interface AuthRepository {
|
||||
suspend fun login(email: String, password: String): Result<User>
|
||||
suspend fun signup(name: String, email: String, password: String): Result<User>
|
||||
suspend fun forgotPassword(email: String): Result<Unit>
|
||||
suspend fun resetPassword(email: String, code: String, password: String): Result<Unit>
|
||||
suspend fun signInWithGoogle(idToken: String): Result<User>
|
||||
fun saveToken(accessToken: String, refreshToken: String?)
|
||||
fun getAccessToken(): String?
|
||||
fun getRefreshToken(): String?
|
||||
fun clearTokens()
|
||||
fun isLoggedIn(): Boolean
|
||||
}
|
||||
|
||||
class AuthRepositoryImpl(
|
||||
context: Context,
|
||||
private val baseUrl: String = "https://api.shieldai.com"
|
||||
) : AuthRepository {
|
||||
|
||||
private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType()
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
|
||||
private val securePrefs: SharedPreferences = EncryptedSharedPreferences.create(
|
||||
context,
|
||||
"shieldai_auth_prefs",
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
|
||||
private fun post(path: String, body: Map<String, String>): JSONObject {
|
||||
val jsonBody = JSONObject(body).toString()
|
||||
val request = Request.Builder()
|
||||
.url("$baseUrl$path")
|
||||
.post(jsonBody.toRequestBody(JSON_MEDIA_TYPE))
|
||||
.build()
|
||||
val response = client.newCall(request).execute()
|
||||
val responseBody = response.body?.string() ?: throw Exception("Empty response")
|
||||
if (!response.isSuccessful) {
|
||||
val errorJson = try {
|
||||
JSONObject(responseBody)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
val message = errorJson?.optString("message", "Request failed") ?: "Request failed"
|
||||
throw Exception(message)
|
||||
}
|
||||
return JSONObject(responseBody)
|
||||
}
|
||||
|
||||
override suspend fun login(email: String, password: String): Result<User> = runCatching {
|
||||
val json = post("/api/auth/login", mapOf(
|
||||
"email" to email,
|
||||
"password" to password
|
||||
))
|
||||
val token = json.getString("accessToken")
|
||||
val refreshToken = if (json.has("refreshToken") && !json.isNull("refreshToken")) json.getString("refreshToken") else null
|
||||
saveToken(token, refreshToken)
|
||||
User(
|
||||
id = json.getString("id"),
|
||||
name = json.getString("name"),
|
||||
email = json.getString("email"),
|
||||
isNewUser = json.optBoolean("isNewUser", false)
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun signup(name: String, email: String, password: String): Result<User> = runCatching {
|
||||
val json = post("/api/auth/signup", mapOf(
|
||||
"name" to name,
|
||||
"email" to email,
|
||||
"password" to password
|
||||
))
|
||||
val token = json.getString("accessToken")
|
||||
val refreshToken = if (json.has("refreshToken") && !json.isNull("refreshToken")) json.getString("refreshToken") else null
|
||||
saveToken(token, refreshToken)
|
||||
User(
|
||||
id = json.getString("id"),
|
||||
name = json.getString("name"),
|
||||
email = json.getString("email"),
|
||||
isNewUser = json.optBoolean("isNewUser", true)
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun forgotPassword(email: String): Result<Unit> = runCatching {
|
||||
post("/api/auth/forgot-password", mapOf("email" to email))
|
||||
Unit
|
||||
}
|
||||
|
||||
override suspend fun resetPassword(email: String, code: String, password: String): Result<Unit> = runCatching {
|
||||
post("/api/auth/reset-password", mapOf(
|
||||
"email" to email,
|
||||
"code" to code,
|
||||
"password" to password
|
||||
))
|
||||
Unit
|
||||
}
|
||||
|
||||
override suspend fun signInWithGoogle(idToken: String): Result<User> = runCatching {
|
||||
val json = post("/api/auth/google", mapOf("idToken" to idToken))
|
||||
val token = json.getString("accessToken")
|
||||
val refreshToken = if (json.has("refreshToken") && !json.isNull("refreshToken")) json.getString("refreshToken") else null
|
||||
saveToken(token, refreshToken)
|
||||
User(
|
||||
id = json.getString("id"),
|
||||
name = json.getString("name"),
|
||||
email = json.getString("email"),
|
||||
isNewUser = json.optBoolean("isNewUser", false)
|
||||
)
|
||||
}
|
||||
|
||||
override fun saveToken(accessToken: String, refreshToken: String?) {
|
||||
securePrefs.edit()
|
||||
.putString("access_token", accessToken)
|
||||
.putString("refresh_token", refreshToken)
|
||||
.apply()
|
||||
}
|
||||
|
||||
override fun getAccessToken(): String? = securePrefs.getString("access_token", null)
|
||||
|
||||
override fun getRefreshToken(): String? = securePrefs.getString("refresh_token", null)
|
||||
|
||||
override fun clearTokens() {
|
||||
securePrefs.edit()
|
||||
.remove("access_token")
|
||||
.remove("refresh_token")
|
||||
.apply()
|
||||
}
|
||||
|
||||
override fun isLoggedIn(): Boolean = getAccessToken() != null
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.shieldai.android.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import com.shieldai.android.data.local.CacheManager
|
||||
import com.shieldai.android.data.model.Exposure
|
||||
import com.shieldai.android.data.model.WatchlistItem
|
||||
import com.shieldai.android.data.remote.ApiResult
|
||||
import com.shieldai.android.data.remote.ErrorHandler
|
||||
import com.shieldai.android.data.remote.TRPCApiService
|
||||
import com.shieldai.android.data.remote.TRPCRequest
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
class DarkWatchRepository(
|
||||
private val api: TRPCApiService,
|
||||
private val context: Context,
|
||||
) {
|
||||
private val _watchlist = MutableStateFlow<List<WatchlistItem>>(emptyList())
|
||||
|
||||
suspend fun getWatchlist(forceRefresh: Boolean = false): ApiResult<List<WatchlistItem>> {
|
||||
if (!forceRefresh) {
|
||||
val cached: List<WatchlistItem>? = CacheManager.load(context, "watchlist")
|
||||
if (cached != null) {
|
||||
_watchlist.value = cached
|
||||
return ApiResult.Success(cached)
|
||||
}
|
||||
}
|
||||
return ErrorHandler.executeWithRetry {
|
||||
val response = api.darkWatchGetWatchlist(TRPCRequest.body(buildJsonObject {}))
|
||||
val items = response.result.data
|
||||
CacheManager.save(context, "watchlist", items)
|
||||
_watchlist.value = items
|
||||
items
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun addWatchlistItem(type: String, value: String, label: String? = null): ApiResult<WatchlistItem> {
|
||||
return ErrorHandler.executeWithRetry {
|
||||
val body = buildJsonObject {
|
||||
put("type", type)
|
||||
put("value", value)
|
||||
label?.let { put("label", it) }
|
||||
}
|
||||
val response = api.darkWatchAddWatchlistItem(TRPCRequest.body(body))
|
||||
val item = response.result.data
|
||||
refreshCache()
|
||||
item
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun removeWatchlistItem(id: String): ApiResult<Unit> {
|
||||
return ErrorHandler.executeWithRetry {
|
||||
val body = buildJsonObject { put("id", id) }
|
||||
api.darkWatchRemoveWatchlistItem(TRPCRequest.body(body))
|
||||
refreshCache()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getExposures(forceRefresh: Boolean = false): ApiResult<List<Exposure>> {
|
||||
if (!forceRefresh) {
|
||||
val cached: List<Exposure>? = CacheManager.load(context, "exposures")
|
||||
if (cached != null) return ApiResult.Success(cached)
|
||||
}
|
||||
return ErrorHandler.executeWithRetry {
|
||||
val response = api.darkWatchGetExposures(TRPCRequest.body(buildJsonObject {}))
|
||||
val exposures = response.result.data
|
||||
CacheManager.save(context, "exposures", exposures)
|
||||
exposures
|
||||
}
|
||||
}
|
||||
|
||||
fun observeWatchlist(): Flow<List<WatchlistItem>> = _watchlist
|
||||
|
||||
private suspend fun refreshCache() {
|
||||
ErrorHandler.executeWithRetry {
|
||||
val response = api.darkWatchGetWatchlist(TRPCRequest.body(buildJsonObject {}))
|
||||
val items = response.result.data
|
||||
CacheManager.save(context, "watchlist", items)
|
||||
_watchlist.value = items
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.shieldai.android.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import com.shieldai.android.data.local.CacheManager
|
||||
import com.shieldai.android.data.model.Subscription
|
||||
import com.shieldai.android.data.remote.ApiResult
|
||||
import com.shieldai.android.data.remote.ErrorHandler
|
||||
import com.shieldai.android.data.remote.TRPCApiService
|
||||
import com.shieldai.android.data.remote.TRPCRequest
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
class SubscriptionRepository(
|
||||
private val api: TRPCApiService,
|
||||
private val context: Context,
|
||||
) {
|
||||
suspend fun getSubscription(): ApiResult<Subscription> {
|
||||
val cached: Subscription? = CacheManager.load(context, "subscription")
|
||||
if (cached != null) return ApiResult.Success(cached)
|
||||
|
||||
return ErrorHandler.executeWithRetry {
|
||||
val response = api.subscriptionGet(TRPCRequest.body(buildJsonObject {}))
|
||||
val subscription = response.result.data
|
||||
CacheManager.save(context, "subscription", subscription)
|
||||
subscription
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateSubscription(plan: String): ApiResult<Subscription> {
|
||||
return ErrorHandler.executeWithRetry {
|
||||
val body = buildJsonObject { put("plan", plan) }
|
||||
val response = api.subscriptionUpdate(TRPCRequest.body(body))
|
||||
val subscription = response.result.data
|
||||
CacheManager.save(context, "subscription", subscription)
|
||||
subscription
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.shieldai.android.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import com.shieldai.android.data.local.CacheManager
|
||||
import com.shieldai.android.data.model.User
|
||||
import com.shieldai.android.data.remote.ApiResult
|
||||
import com.shieldai.android.data.remote.ErrorHandler
|
||||
import com.shieldai.android.data.remote.TRPCApiService
|
||||
import com.shieldai.android.data.remote.TRPCRequest
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
|
||||
class UserRepository(
|
||||
private val api: TRPCApiService,
|
||||
private val context: Context,
|
||||
) {
|
||||
private val _currentUser = MutableStateFlow<User?>(null)
|
||||
|
||||
suspend fun getMe(forceRefresh: Boolean = false): ApiResult<User> {
|
||||
if (!forceRefresh) {
|
||||
val cached: User? = CacheManager.load(context, "current_user")
|
||||
if (cached != null) {
|
||||
_currentUser.value = cached
|
||||
return ApiResult.Success(cached)
|
||||
}
|
||||
}
|
||||
return ErrorHandler.executeWithRetry {
|
||||
val response = api.userMe(TRPCRequest.body(buildJsonObject {}))
|
||||
val user = response.result.data
|
||||
CacheManager.save(context, "current_user", user)
|
||||
_currentUser.value = user
|
||||
user
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateProfile(name: String? = null, phone: String? = null): ApiResult<User> {
|
||||
return ErrorHandler.executeWithRetry {
|
||||
val body = buildJsonObject {
|
||||
name?.let { put("name", kotlinx.serialization.json.JsonPrimitive(it)) }
|
||||
phone?.let { put("phone", kotlinx.serialization.json.JsonPrimitive(it)) }
|
||||
}
|
||||
val response = api.userUpdateProfile(TRPCRequest.body(body))
|
||||
val user = response.result.data
|
||||
CacheManager.save(context, "current_user", user)
|
||||
_currentUser.value = user
|
||||
user
|
||||
}
|
||||
}
|
||||
|
||||
fun observeCurrentUser(): Flow<User?> = _currentUser
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.shieldai.android.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import com.shieldai.android.data.local.CacheManager
|
||||
import com.shieldai.android.data.model.VoiceAnalysis
|
||||
import com.shieldai.android.data.model.VoiceEnrollment
|
||||
import com.shieldai.android.data.remote.ApiResult
|
||||
import com.shieldai.android.data.remote.ErrorHandler
|
||||
import com.shieldai.android.data.remote.TRPCApiService
|
||||
import com.shieldai.android.data.remote.TRPCRequest
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
class VoicePrintRepository(
|
||||
private val api: TRPCApiService,
|
||||
private val context: Context,
|
||||
) {
|
||||
private val _enrollments = MutableStateFlow<List<VoiceEnrollment>>(emptyList())
|
||||
|
||||
suspend fun getEnrollments(): ApiResult<List<VoiceEnrollment>> {
|
||||
val cached: List<VoiceEnrollment>? = CacheManager.load(context, "voice_enrollments")
|
||||
if (cached != null) {
|
||||
_enrollments.value = cached
|
||||
return ApiResult.Success(cached)
|
||||
}
|
||||
return ErrorHandler.executeWithRetry {
|
||||
val response = api.voiceEnrollments(TRPCRequest.body(buildJsonObject {}))
|
||||
val enrollments = response.result.data
|
||||
CacheManager.save(context, "voice_enrollments", enrollments)
|
||||
_enrollments.value = enrollments
|
||||
enrollments
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun createEnrollment(name: String): ApiResult<VoiceEnrollment> {
|
||||
return ErrorHandler.executeWithRetry {
|
||||
val body = buildJsonObject { put("name", name) }
|
||||
val response = api.voiceCreateEnrollment(TRPCRequest.body(body))
|
||||
val enrollment = response.result.data
|
||||
refreshEnrollmentsCache()
|
||||
enrollment
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun analyze(enrollmentId: String, audioData: String): ApiResult<VoiceAnalysis> {
|
||||
return ErrorHandler.executeWithRetry {
|
||||
val body = buildJsonObject {
|
||||
put("enrollmentId", enrollmentId)
|
||||
put("audioData", audioData)
|
||||
}
|
||||
val response = api.voiceAnalyze(TRPCRequest.body(body))
|
||||
response.result.data
|
||||
}
|
||||
}
|
||||
|
||||
fun observeEnrollments(): Flow<List<VoiceEnrollment>> = _enrollments
|
||||
|
||||
private suspend fun refreshEnrollmentsCache() {
|
||||
ErrorHandler.executeWithRetry {
|
||||
val response = api.voiceEnrollments(TRPCRequest.body(buildJsonObject {}))
|
||||
val enrollments = response.result.data
|
||||
CacheManager.save(context, "voice_enrollments", enrollments)
|
||||
_enrollments.value = enrollments
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.shieldai.android.data.sync
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
|
||||
class OfflineWorker(
|
||||
appContext: Context,
|
||||
params: WorkerParameters,
|
||||
) : CoroutineWorker(appContext, params) {
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val queue = PendingRequestQueue(applicationContext)
|
||||
val pendingRequests = queue.getAll()
|
||||
if (pendingRequests.isEmpty()) return Result.success()
|
||||
|
||||
val client = OkHttpClient.Builder().build()
|
||||
val jsonMediaType = "application/json; charset=utf-8".toMediaType()
|
||||
|
||||
for (request in pendingRequests) {
|
||||
if (request.retryCount >= request.maxRetries) {
|
||||
queue.deleteById(request.id)
|
||||
continue
|
||||
}
|
||||
try {
|
||||
val body = request.body.toRequestBody(jsonMediaType)
|
||||
val httpRequest = Request.Builder()
|
||||
.url("https://api.shieldai.com/${request.endpoint}")
|
||||
.method(request.method, body)
|
||||
.build()
|
||||
val response = client.newCall(httpRequest).execute()
|
||||
if (response.isSuccessful) {
|
||||
queue.deleteById(request.id)
|
||||
} else {
|
||||
queue.incrementRetry(request.id)
|
||||
if (response.code == 422 || response.code == 400) {
|
||||
queue.deleteById(request.id)
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
queue.incrementRetry(request.id)
|
||||
return Result.retry()
|
||||
}
|
||||
}
|
||||
queue.deleteExpired()
|
||||
return if (queue.count() == 0) Result.success() else Result.retry()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.shieldai.android.data.sync
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
|
||||
@Serializable
|
||||
data class PendingRequest(
|
||||
val id: Long = 0,
|
||||
val endpoint: String,
|
||||
val method: String = "POST",
|
||||
val body: String,
|
||||
val timestamp: Long = System.currentTimeMillis(),
|
||||
val retryCount: Int = 0,
|
||||
val maxRetries: Int = 5,
|
||||
)
|
||||
|
||||
class PendingRequestQueue(private val context: Context) {
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
private val file: File get() = File(context.cacheDir, "pending_requests.json")
|
||||
|
||||
fun getAll(): List<PendingRequest> {
|
||||
if (!file.exists()) return emptyList()
|
||||
return try {
|
||||
json.decodeFromString<List<PendingRequest>>(file.readText())
|
||||
} catch (_: Exception) {
|
||||
file.delete()
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveAll(requests: List<PendingRequest>) {
|
||||
file.writeText(json.encodeToString(requests))
|
||||
}
|
||||
|
||||
fun insert(request: PendingRequest) {
|
||||
val requests = getAll().toMutableList()
|
||||
val newId = (requests.maxOfOrNull { it.id } ?: 0) + 1
|
||||
requests.add(request.copy(id = newId))
|
||||
saveAll(requests)
|
||||
}
|
||||
|
||||
fun incrementRetry(id: Long) {
|
||||
val requests = getAll().map {
|
||||
if (it.id == id) it.copy(retryCount = it.retryCount + 1) else it
|
||||
}
|
||||
saveAll(requests)
|
||||
}
|
||||
|
||||
fun deleteById(id: Long) {
|
||||
val requests = getAll().filter { it.id != id }
|
||||
saveAll(requests)
|
||||
}
|
||||
|
||||
fun deleteExpired() {
|
||||
val requests = getAll().filter { it.retryCount < it.maxRetries }
|
||||
saveAll(requests)
|
||||
}
|
||||
|
||||
fun deleteAll() {
|
||||
file.delete()
|
||||
}
|
||||
|
||||
fun count(): Int = getAll().size
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.shieldai.android.data.sync
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import androidx.work.BackoffPolicy
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class SyncManager(private val context: Context) {
|
||||
|
||||
private val connectivityManager =
|
||||
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
|
||||
private val queue = PendingRequestQueue(context)
|
||||
|
||||
fun enqueueRequest(endpoint: String, body: String, method: String = "POST") {
|
||||
val request = PendingRequest(
|
||||
endpoint = endpoint,
|
||||
method = method,
|
||||
body = body,
|
||||
)
|
||||
queue.insert(request)
|
||||
scheduleSync()
|
||||
}
|
||||
|
||||
fun scheduleSync(delayMinutes: Long = 0) {
|
||||
val workRequest = OneTimeWorkRequestBuilder<OfflineWorker>()
|
||||
.setInitialDelay(delayMinutes, TimeUnit.MINUTES)
|
||||
.build()
|
||||
|
||||
WorkManager.getInstance(context)
|
||||
.enqueueUniqueWork(
|
||||
"offline_sync",
|
||||
ExistingWorkPolicy.REPLACE,
|
||||
workRequest,
|
||||
)
|
||||
}
|
||||
|
||||
fun queueSize(): Int = queue.count()
|
||||
|
||||
fun startMonitoring() {
|
||||
val networkCallback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
if (queueSize() > 0) {
|
||||
scheduleSync()
|
||||
}
|
||||
}
|
||||
}
|
||||
val request = NetworkRequest.Builder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.build()
|
||||
connectivityManager.registerNetworkCallback(request, networkCallback)
|
||||
}
|
||||
|
||||
fun isOnline(): Boolean {
|
||||
val network = connectivityManager.activeNetwork ?: return false
|
||||
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
|
||||
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.shieldai.android.di
|
||||
|
||||
import android.content.Context
|
||||
import com.shieldai.android.data.local.CacheManager
|
||||
|
||||
object DatabaseModule {
|
||||
fun initializeCache(context: Context) {
|
||||
CacheManager.setTtl("users", 5 * 60 * 1000L)
|
||||
CacheManager.setTtl("current_user", 5 * 60 * 1000L)
|
||||
CacheManager.setTtl("watchlist", 5 * 60 * 1000L)
|
||||
CacheManager.setTtl("exposures", 5 * 60 * 1000L)
|
||||
CacheManager.setTtl("alerts", 5 * 60 * 1000L)
|
||||
CacheManager.setTtl("subscription", 5 * 60 * 1000L)
|
||||
CacheManager.setTtl("voice_enrollments", 10 * 60 * 1000L)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.shieldai.android.di
|
||||
|
||||
import android.content.Context
|
||||
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||
import com.shieldai.android.data.remote.AuthInterceptor
|
||||
import com.shieldai.android.data.remote.TRPCApiService
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object NetworkModule {
|
||||
private var baseUrl: String = "http://10.0.2.2:3000/"
|
||||
private var retrofit: Retrofit? = null
|
||||
private var apiService: TRPCApiService? = null
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
fun setBaseUrl(url: String) {
|
||||
baseUrl = if (url.endsWith("/")) url else "$url/"
|
||||
retrofit = null
|
||||
apiService = null
|
||||
}
|
||||
|
||||
fun getBaseUrl(): String = baseUrl
|
||||
|
||||
fun provideOkHttpClient(context: Context): OkHttpClient {
|
||||
return OkHttpClient.Builder()
|
||||
.addInterceptor(AuthInterceptor(context))
|
||||
.addInterceptor(HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BODY
|
||||
})
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun provideRetrofit(context: Context): Retrofit {
|
||||
return retrofit ?: synchronized(this) {
|
||||
retrofit ?: Retrofit.Builder()
|
||||
.baseUrl(baseUrl)
|
||||
.client(provideOkHttpClient(context))
|
||||
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
||||
.build()
|
||||
.also { retrofit = it }
|
||||
}
|
||||
}
|
||||
|
||||
fun provideApiService(context: Context): TRPCApiService {
|
||||
return apiService ?: synchronized(this) {
|
||||
apiService ?: provideRetrofit(context).create(TRPCApiService::class.java)
|
||||
.also { apiService = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.shieldai.android.di
|
||||
|
||||
import android.content.Context
|
||||
import com.shieldai.android.data.repository.AlertRepository
|
||||
import com.shieldai.android.data.repository.DarkWatchRepository
|
||||
import com.shieldai.android.data.repository.SubscriptionRepository
|
||||
import com.shieldai.android.data.repository.UserRepository
|
||||
import com.shieldai.android.data.repository.VoicePrintRepository
|
||||
|
||||
object RepositoryModule {
|
||||
private var userRepository: UserRepository? = null
|
||||
private var darkWatchRepository: DarkWatchRepository? = null
|
||||
private var voicePrintRepository: VoicePrintRepository? = null
|
||||
private var alertRepository: AlertRepository? = null
|
||||
private var subscriptionRepository: SubscriptionRepository? = null
|
||||
|
||||
fun provideUserRepository(context: Context): UserRepository {
|
||||
return userRepository ?: synchronized(this) {
|
||||
UserRepository(
|
||||
api = NetworkModule.provideApiService(context),
|
||||
context = context,
|
||||
).also { userRepository = it }
|
||||
}
|
||||
}
|
||||
|
||||
fun provideDarkWatchRepository(context: Context): DarkWatchRepository {
|
||||
return darkWatchRepository ?: synchronized(this) {
|
||||
DarkWatchRepository(
|
||||
api = NetworkModule.provideApiService(context),
|
||||
context = context,
|
||||
).also { darkWatchRepository = it }
|
||||
}
|
||||
}
|
||||
|
||||
fun provideVoicePrintRepository(context: Context): VoicePrintRepository {
|
||||
return voicePrintRepository ?: synchronized(this) {
|
||||
VoicePrintRepository(
|
||||
api = NetworkModule.provideApiService(context),
|
||||
context = context,
|
||||
).also { voicePrintRepository = it }
|
||||
}
|
||||
}
|
||||
|
||||
fun provideAlertRepository(context: Context): AlertRepository {
|
||||
return alertRepository ?: synchronized(this) {
|
||||
AlertRepository(
|
||||
api = NetworkModule.provideApiService(context),
|
||||
context = context,
|
||||
).also { alertRepository = it }
|
||||
}
|
||||
}
|
||||
|
||||
fun provideSubscriptionRepository(context: Context): SubscriptionRepository {
|
||||
return subscriptionRepository ?: synchronized(this) {
|
||||
SubscriptionRepository(
|
||||
api = NetworkModule.provideApiService(context),
|
||||
context = context,
|
||||
).also { subscriptionRepository = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.shieldai.android.navigation
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.shieldai.android.ShieldAIApp
|
||||
import com.shieldai.android.viewmodel.AuthViewModel
|
||||
|
||||
@Composable
|
||||
fun AppNavigation() {
|
||||
val context = LocalContext.current
|
||||
val app = context.applicationContext as ShieldAIApp
|
||||
val viewModel: AuthViewModel = viewModel(
|
||||
factory = AuthViewModel.Factory
|
||||
)
|
||||
val isAuthenticated by viewModel.isAuthenticated.collectAsState()
|
||||
val isNewUser by viewModel.isNewUser.collectAsState()
|
||||
|
||||
if (isAuthenticated) {
|
||||
if (isNewUser) {
|
||||
OnboardingNavHost(
|
||||
viewModel = viewModel,
|
||||
onComplete = {
|
||||
viewModel.completeOnboarding()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
val navController = rememberNavController()
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = navBackStackEntry?.destination?.route
|
||||
|
||||
val bottomNavScreens = setOf(
|
||||
Screen.Dashboard.route,
|
||||
Screen.Services.route,
|
||||
Screen.Alerts.route,
|
||||
Screen.Settings.route,
|
||||
Screen.Account.route
|
||||
)
|
||||
val showBottomBar = currentRoute in bottomNavScreens
|
||||
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
if (showBottomBar) {
|
||||
BottomNavBar(
|
||||
currentRoute = currentRoute,
|
||||
onNavigate = { screen ->
|
||||
navController.navigate(screen.route) {
|
||||
popUpTo(navController.graph.startDestinationId)
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
NavGraph(
|
||||
navController = navController,
|
||||
viewModel = viewModel,
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
AuthNavHost(viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.shieldai.android.navigation
|
||||
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import com.shieldai.android.R
|
||||
|
||||
data class BottomNavItem(
|
||||
val screen: Screen,
|
||||
val label: String,
|
||||
val icon: ImageVector
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun BottomNavBar(
|
||||
currentRoute: String?,
|
||||
onNavigate: (Screen) -> Unit
|
||||
) {
|
||||
val items = listOf(
|
||||
BottomNavItem(Screen.Dashboard, "Dashboard", ImageVector.vectorResource(R.drawable.ic_dashboard)),
|
||||
BottomNavItem(Screen.Services, "Services", ImageVector.vectorResource(R.drawable.ic_services)),
|
||||
BottomNavItem(Screen.Alerts, "Alerts", ImageVector.vectorResource(R.drawable.ic_alerts)),
|
||||
BottomNavItem(Screen.Settings, "Settings", ImageVector.vectorResource(R.drawable.ic_settings)),
|
||||
BottomNavItem(Screen.Account, "Account", ImageVector.vectorResource(R.drawable.ic_account_box))
|
||||
)
|
||||
|
||||
NavigationBar {
|
||||
items.forEach { item ->
|
||||
NavigationBarItem(
|
||||
icon = { Icon(item.icon, contentDescription = item.label) },
|
||||
label = { Text(item.label) },
|
||||
selected = currentRoute == item.screen.route,
|
||||
onClick = { onNavigate(item.screen) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package com.shieldai.android.navigation
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.shieldai.android.ui.screens.auth.AuthScreen
|
||||
import com.shieldai.android.ui.screens.auth.ForgotPasswordScreen
|
||||
import com.shieldai.android.ui.screens.auth.ResetPasswordScreen
|
||||
import com.shieldai.android.ui.screens.onboarding.OnboardingScreen
|
||||
import com.shieldai.android.viewmodel.AuthViewModel
|
||||
|
||||
@Composable
|
||||
fun NavGraph(
|
||||
navController: NavHostController,
|
||||
viewModel: AuthViewModel,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Screen.Dashboard.route,
|
||||
modifier = modifier
|
||||
) {
|
||||
composable(Screen.Dashboard.route) {
|
||||
PlaceholderScreen(title = "Dashboard")
|
||||
}
|
||||
composable(Screen.Services.route) {
|
||||
PlaceholderScreen(title = "Services")
|
||||
}
|
||||
composable(Screen.Alerts.route) {
|
||||
PlaceholderScreen(title = "Alerts")
|
||||
}
|
||||
composable(Screen.Settings.route) {
|
||||
PlaceholderScreen(title = "Settings")
|
||||
}
|
||||
composable(Screen.Account.route) {
|
||||
PlaceholderScreen(title = "Account")
|
||||
}
|
||||
composable(
|
||||
route = Screen.ServiceDetail.ROUTE,
|
||||
arguments = listOf(navArgument("serviceId") { type = NavType.StringType })
|
||||
) { backStackEntry ->
|
||||
val serviceId = backStackEntry.arguments?.getString("serviceId") ?: ""
|
||||
PlaceholderScreen(title = "Service: $serviceId")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AuthNavHost(viewModel: AuthViewModel) {
|
||||
val navController = rememberNavController()
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Screen.Auth.route
|
||||
) {
|
||||
composable(Screen.Auth.route) {
|
||||
AuthScreen(
|
||||
viewModel = viewModel,
|
||||
onNavigateToForgotPassword = {
|
||||
navController.navigate(Screen.ForgotPassword.route)
|
||||
},
|
||||
onNavigateToResetPassword = {
|
||||
navController.navigate(Screen.ResetPassword.createRoute(""))
|
||||
}
|
||||
)
|
||||
}
|
||||
composable(Screen.ForgotPassword.route) {
|
||||
ForgotPasswordScreen(
|
||||
viewModel = viewModel,
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Screen.ResetPassword.route,
|
||||
arguments = listOf(navArgument("email") { type = NavType.StringType; defaultValue = "" })
|
||||
) { backStackEntry ->
|
||||
val email = backStackEntry.arguments?.getString("email") ?: ""
|
||||
ResetPasswordScreen(
|
||||
viewModel = viewModel,
|
||||
email = email,
|
||||
onBack = { navController.popBackStack(Screen.Auth.route, false) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun OnboardingNavHost(
|
||||
viewModel: AuthViewModel,
|
||||
onComplete: () -> Unit
|
||||
) {
|
||||
OnboardingScreen(
|
||||
viewModel = viewModel,
|
||||
onComplete = onComplete
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlaceholderScreen(title: String) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.shieldai.android.navigation
|
||||
|
||||
sealed class Screen(val route: String) {
|
||||
data object Dashboard : Screen("dashboard")
|
||||
data object Services : Screen("services")
|
||||
data object Alerts : Screen("alerts")
|
||||
data object Settings : Screen("settings")
|
||||
data object Account : Screen("account")
|
||||
data object Auth : Screen("auth")
|
||||
data object ForgotPassword : Screen("forgot_password")
|
||||
data object ResetPassword : Screen("reset_password/{email}") {
|
||||
fun createRoute(email: String) = "reset_password/$email"
|
||||
}
|
||||
data object Onboarding : Screen("onboarding")
|
||||
data class ServiceDetail(val serviceId: String) : Screen("service_detail/{serviceId}") {
|
||||
companion object {
|
||||
const val ROUTE = "service_detail/{serviceId}"
|
||||
fun createRoute(serviceId: String) = "service_detail/$serviceId"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package com.shieldai.android.ui.components
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.shieldai.android.ui.theme.ShieldAITheme
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ComponentShowcase(modifier: Modifier = Modifier) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
var textFieldValue by remember { mutableStateOf("") }
|
||||
var showSheet by remember { mutableStateOf(false) }
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "ShieldAI Design System",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
SectionTitle("ShieldButton")
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
ShieldButton(text = "Primary", onClick = {}, variant = ShieldButtonVariant.Primary, size = ShieldButtonSize.Small)
|
||||
ShieldButton(text = "Secondary", onClick = {}, variant = ShieldButtonVariant.Secondary, size = ShieldButtonSize.Small)
|
||||
ShieldButton(text = "Ghost", onClick = {}, variant = ShieldButtonVariant.Ghost, size = ShieldButtonSize.Small)
|
||||
ShieldButton(text = "Danger", onClick = {}, variant = ShieldButtonVariant.Danger, size = ShieldButtonSize.Small)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
ShieldButton(text = "Loading", onClick = {}, loading = true)
|
||||
ShieldButton(text = "Disabled", onClick = {}, enabled = false)
|
||||
}
|
||||
ShieldButton(text = "Full Width", onClick = {}, variant = ShieldButtonVariant.Primary, fullWidth = true)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
SectionTitle("ShieldCard")
|
||||
ShieldCard(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
header = { Text("Card Header", style = MaterialTheme.typography.titleMedium) },
|
||||
footer = {
|
||||
ShieldButton(text = "Action", onClick = {}, size = ShieldButtonSize.Small)
|
||||
},
|
||||
content = {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text("This is the card content area. It uses a gradient background matching the web theme.", style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
SectionTitle("ShieldTextField")
|
||||
ShieldTextField(
|
||||
value = textFieldValue,
|
||||
onValueChange = { textFieldValue = it },
|
||||
label = "Email",
|
||||
placeholder = "Enter your email",
|
||||
inputType = InputType.Email
|
||||
)
|
||||
ShieldTextField(
|
||||
value = "",
|
||||
onValueChange = {},
|
||||
label = "Password",
|
||||
inputType = InputType.Password
|
||||
)
|
||||
ShieldTextField(
|
||||
value = "invalid",
|
||||
onValueChange = {},
|
||||
label = "With Error",
|
||||
isError = true,
|
||||
errorMessage = "This field is required"
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
SectionTitle("ShieldBadge")
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
ShieldBadge(text = "Default", variant = BadgeVariant.Default)
|
||||
ShieldBadge(text = "Success", variant = BadgeVariant.Success)
|
||||
ShieldBadge(text = "Warning", variant = BadgeVariant.Warning)
|
||||
ShieldBadge(text = "Error", variant = BadgeVariant.Error)
|
||||
ShieldBadge(text = "Info", variant = BadgeVariant.Info)
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
SectionTitle("ShieldAvatar")
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
ShieldAvatar(imageUrl = null, name = "John Doe", size = AvatarSize.Small)
|
||||
ShieldAvatar(imageUrl = null, name = "Jane Smith", size = AvatarSize.Medium, isOnline = true)
|
||||
ShieldAvatar(imageUrl = null, name = "Alice", size = AvatarSize.Large, isOnline = true)
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
SectionTitle("ShieldProgressBar")
|
||||
ShieldProgressBar(progress = 0.3f, color = ProgressColor.Primary, showPercentage = true)
|
||||
ShieldProgressBar(progress = 0.6f, color = ProgressColor.Accent, showPercentage = true)
|
||||
ShieldProgressBar(progress = 0.9f, color = ProgressColor.Success, showPercentage = true)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
SectionTitle("ShieldEmptyState")
|
||||
ShieldEmptyState(
|
||||
title = "No items found",
|
||||
description = "Try adjusting your search or filters to find what you're looking for.",
|
||||
actionButton = {
|
||||
ShieldButton(text = "Clear Filters", onClick = {}, variant = ShieldButtonVariant.Secondary)
|
||||
}
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
SectionTitle("ShieldSkeleton")
|
||||
ShieldSkeletonCard(modifier = Modifier.fillMaxWidth(), lines = 3)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
SectionTitle("ShieldToast")
|
||||
ShieldButton(
|
||||
text = "Show Success Toast",
|
||||
onClick = {
|
||||
scope.launch {
|
||||
snackbarHostState.showSnackbar(
|
||||
ShieldSnackbarVisuals(
|
||||
message = "Operation completed successfully!",
|
||||
variant = ToastVariant.Success,
|
||||
duration = SnackbarDuration.Short
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
variant = ShieldButtonVariant.Primary,
|
||||
fullWidth = true
|
||||
)
|
||||
ShieldButton(
|
||||
text = "Show Error Toast with Action",
|
||||
onClick = {
|
||||
scope.launch {
|
||||
snackbarHostState.showSnackbar(
|
||||
ShieldSnackbarVisuals(
|
||||
message = "Something went wrong.",
|
||||
actionLabel = "Retry",
|
||||
variant = ToastVariant.Error,
|
||||
duration = SnackbarDuration.Long
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
variant = ShieldButtonVariant.Danger,
|
||||
fullWidth = true
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
SectionTitle("ShieldModal")
|
||||
ShieldButton(
|
||||
text = "Show Bottom Sheet",
|
||||
onClick = { showSheet = true },
|
||||
variant = ShieldButtonVariant.Secondary,
|
||||
fullWidth = true
|
||||
)
|
||||
ShieldButton(
|
||||
text = "Show Alert Dialog",
|
||||
onClick = { showDialog = true },
|
||||
variant = ShieldButtonVariant.Ghost,
|
||||
fullWidth = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(80.dp))
|
||||
}
|
||||
|
||||
ShieldToastHost(
|
||||
hostState = snackbarHostState,
|
||||
modifier = Modifier.align(Alignment.BottomCenter)
|
||||
)
|
||||
|
||||
if (showSheet) {
|
||||
ShieldBottomSheet(
|
||||
onDismiss = { showSheet = false },
|
||||
title = "Bottom Sheet Title",
|
||||
actions = listOf(
|
||||
ModalAction(text = "Save", onClick = { showSheet = false }, isPrimary = true),
|
||||
ModalAction(text = "Cancel", onClick = { showSheet = false })
|
||||
)
|
||||
) {
|
||||
Text("This is the bottom sheet content area.")
|
||||
}
|
||||
}
|
||||
|
||||
if (showDialog) {
|
||||
ShieldAlertDialog(
|
||||
onDismiss = { showDialog = false },
|
||||
onConfirm = { showDialog = false },
|
||||
title = "Confirm Action",
|
||||
message = "Are you sure you want to proceed?",
|
||||
confirmText = "Yes, Continue",
|
||||
dismissText = "Cancel"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionTitle(title: String) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, name = "Light Mode")
|
||||
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, name = "Dark Mode")
|
||||
@Composable
|
||||
fun ComponentShowcasePreview() {
|
||||
ShieldAITheme {
|
||||
ComponentShowcase()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.shieldai.android.ui.components
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import com.shieldai.android.ui.theme.BrandPrimary
|
||||
import com.shieldai.android.ui.theme.Success
|
||||
|
||||
enum class AvatarSize(val dimension: Dp, val fontSize: TextUnit) {
|
||||
Small(32.dp, 12.sp),
|
||||
Medium(40.dp, 16.sp),
|
||||
Large(56.dp, 24.sp)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ShieldAvatar(
|
||||
imageUrl: String?,
|
||||
name: String,
|
||||
modifier: Modifier = Modifier,
|
||||
size: AvatarSize = AvatarSize.Medium,
|
||||
isOnline: Boolean = false
|
||||
) {
|
||||
val initials = remember(name) {
|
||||
name.split(" ")
|
||||
.take(2)
|
||||
.mapNotNull { it.firstOrNull()?.uppercase() }
|
||||
.joinToString("")
|
||||
}
|
||||
val statusDotSize = (size.dimension / 4).coerceAtLeast(8.dp)
|
||||
|
||||
Box(
|
||||
modifier = modifier.size(size.dimension),
|
||||
contentAlignment = Alignment.BottomEnd
|
||||
) {
|
||||
if (imageUrl != null) {
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = name,
|
||||
modifier = Modifier
|
||||
.size(size.dimension)
|
||||
.clip(CircleShape),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(size.dimension)
|
||||
.clip(CircleShape)
|
||||
.background(BrandPrimary),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = initials,
|
||||
color = Color.White,
|
||||
fontSize = size.fontSize,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isOnline) {
|
||||
Canvas(
|
||||
modifier = Modifier.size(statusDotSize)
|
||||
) {
|
||||
val radius = statusDotSize.toPx() / 2
|
||||
drawCircle(
|
||||
color = Color.White,
|
||||
radius = radius
|
||||
)
|
||||
drawCircle(
|
||||
color = Success,
|
||||
radius = radius - 1.5.dp.toPx()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.shieldai.android.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.shieldai.android.ui.theme.Error
|
||||
import com.shieldai.android.ui.theme.Info
|
||||
import com.shieldai.android.ui.theme.Success
|
||||
import com.shieldai.android.ui.theme.TextPrimaryLight
|
||||
import com.shieldai.android.ui.theme.TextSecondaryLight
|
||||
import com.shieldai.android.ui.theme.Warning
|
||||
|
||||
enum class BadgeVariant {
|
||||
Default, Success, Warning, Error, Info
|
||||
}
|
||||
|
||||
data class BadgeColors(
|
||||
val background: Color,
|
||||
val content: Color
|
||||
)
|
||||
|
||||
fun badgeColors(variant: BadgeVariant): BadgeColors = when (variant) {
|
||||
BadgeVariant.Default -> BadgeColors(
|
||||
background = Color(0xFFF1F5F9),
|
||||
content = TextSecondaryLight
|
||||
)
|
||||
BadgeVariant.Success -> BadgeColors(
|
||||
background = Success.copy(alpha = 0.15f),
|
||||
content = Success
|
||||
)
|
||||
BadgeVariant.Warning -> BadgeColors(
|
||||
background = Warning.copy(alpha = 0.15f),
|
||||
content = Warning
|
||||
)
|
||||
BadgeVariant.Error -> BadgeColors(
|
||||
background = Error.copy(alpha = 0.15f),
|
||||
content = Error
|
||||
)
|
||||
BadgeVariant.Info -> BadgeColors(
|
||||
background = Info.copy(alpha = 0.15f),
|
||||
content = Info
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ShieldBadge(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
variant: BadgeVariant = BadgeVariant.Default,
|
||||
icon: Painter? = null
|
||||
) {
|
||||
val colors = badgeColors(variant)
|
||||
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(50),
|
||||
color = colors.background,
|
||||
contentColor = colors.content
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (icon != null) {
|
||||
Icon(
|
||||
painter = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = colors.content
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
}
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package com.shieldai.android.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.shieldai.android.ui.theme.BrandPrimary
|
||||
import com.shieldai.android.ui.theme.Error
|
||||
|
||||
enum class ShieldButtonVariant {
|
||||
Primary, Secondary, Ghost, Danger
|
||||
}
|
||||
|
||||
enum class ShieldButtonSize {
|
||||
Small, Medium, Large
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ShieldButton(
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
variant: ShieldButtonVariant = ShieldButtonVariant.Primary,
|
||||
size: ShieldButtonSize = ShieldButtonSize.Medium,
|
||||
enabled: Boolean = true,
|
||||
loading: Boolean = false,
|
||||
leadingIcon: @Composable (() -> Unit)? = null,
|
||||
trailingIcon: @Composable (() -> Unit)? = null,
|
||||
fullWidth: Boolean = false
|
||||
) {
|
||||
val buttonModifier = if (fullWidth) modifier.fillMaxWidth() else modifier
|
||||
val sizeModifier = when (size) {
|
||||
ShieldButtonSize.Small -> Modifier.height(32.dp)
|
||||
ShieldButtonSize.Medium -> Modifier.height(40.dp)
|
||||
ShieldButtonSize.Large -> Modifier.height(48.dp)
|
||||
}
|
||||
val paddingModifier = when (size) {
|
||||
ShieldButtonSize.Small -> Modifier.padding(horizontal = 12.dp)
|
||||
ShieldButtonSize.Medium -> Modifier.padding(horizontal = 16.dp)
|
||||
ShieldButtonSize.Large -> Modifier.padding(horizontal = 20.dp)
|
||||
}
|
||||
val indicatorSize = when (size) {
|
||||
ShieldButtonSize.Small -> 16.dp
|
||||
ShieldButtonSize.Medium -> 20.dp
|
||||
ShieldButtonSize.Large -> 24.dp
|
||||
}
|
||||
val contentColor = when {
|
||||
variant == ShieldButtonVariant.Ghost -> BrandPrimary
|
||||
variant == ShieldButtonVariant.Secondary -> BrandPrimary
|
||||
else -> Color.White
|
||||
}
|
||||
val containerColor = when (variant) {
|
||||
ShieldButtonVariant.Primary -> BrandPrimary
|
||||
ShieldButtonVariant.Danger -> Error
|
||||
else -> Color.Transparent
|
||||
}
|
||||
|
||||
val mergedEnabled = enabled && !loading
|
||||
|
||||
val content: @Composable RowScope.() -> Unit = {
|
||||
if (loading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(indicatorSize),
|
||||
color = if (variant == ShieldButtonVariant.Ghost || variant == ShieldButtonVariant.Secondary)
|
||||
BrandPrimary else Color.White,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
leadingIcon?.let {
|
||||
it()
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
Text(
|
||||
text = text,
|
||||
style = when (size) {
|
||||
ShieldButtonSize.Small -> MaterialTheme.typography.labelSmall
|
||||
ShieldButtonSize.Medium -> MaterialTheme.typography.labelLarge
|
||||
ShieldButtonSize.Large -> MaterialTheme.typography.titleSmall
|
||||
}
|
||||
)
|
||||
trailingIcon?.let {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
it()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (variant) {
|
||||
ShieldButtonVariant.Primary, ShieldButtonVariant.Danger -> {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
modifier = buttonModifier.then(sizeModifier).then(paddingModifier),
|
||||
enabled = mergedEnabled,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = containerColor,
|
||||
contentColor = contentColor,
|
||||
disabledContainerColor = containerColor.copy(alpha = 0.4f),
|
||||
disabledContentColor = contentColor.copy(alpha = 0.4f)
|
||||
),
|
||||
shape = MaterialTheme.shapes.small,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
ShieldButtonVariant.Secondary -> {
|
||||
OutlinedButton(
|
||||
onClick = onClick,
|
||||
modifier = buttonModifier.then(sizeModifier).then(paddingModifier),
|
||||
enabled = mergedEnabled,
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = BrandPrimary,
|
||||
disabledContentColor = BrandPrimary.copy(alpha = 0.4f)
|
||||
),
|
||||
border = ButtonDefaults.outlinedButtonBorder(enabled = mergedEnabled),
|
||||
shape = MaterialTheme.shapes.small,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
ShieldButtonVariant.Ghost -> {
|
||||
TextButton(
|
||||
onClick = onClick,
|
||||
modifier = buttonModifier.then(sizeModifier).then(paddingModifier),
|
||||
enabled = mergedEnabled,
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = BrandPrimary,
|
||||
disabledContentColor = BrandPrimary.copy(alpha = 0.4f)
|
||||
),
|
||||
shape = MaterialTheme.shapes.small,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.shieldai.android.ui.components
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.shieldai.android.ui.theme.BrandAccent
|
||||
import com.shieldai.android.ui.theme.BrandPrimary
|
||||
import com.shieldai.android.ui.theme.OutlineLight
|
||||
|
||||
val GradientCardBrush = Brush.linearGradient(
|
||||
colors = listOf(
|
||||
BrandPrimary.copy(alpha = 0.08f),
|
||||
BrandAccent.copy(alpha = 0.05f)
|
||||
)
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun ShieldCard(
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: (() -> Unit)? = null,
|
||||
header: @Composable ColumnScope.() -> Unit = {},
|
||||
footer: @Composable ColumnScope.() -> Unit = {},
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
Card(
|
||||
onClick = onClick ?: {},
|
||||
enabled = onClick != null,
|
||||
modifier = modifier,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
border = BorderStroke(1.dp, OutlineLight),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = Color.Transparent
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(GradientCardBrush)
|
||||
.padding(16.dp)
|
||||
) {
|
||||
header()
|
||||
content()
|
||||
footer()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.shieldai.android.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun ShieldEmptyState(
|
||||
title: String,
|
||||
description: String,
|
||||
modifier: Modifier = Modifier,
|
||||
icon: Painter? = null,
|
||||
actionButton: @Composable (() -> Unit)? = null
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
if (icon != null) {
|
||||
Icon(
|
||||
painter = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
if (actionButton != null) {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
actionButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package com.shieldai.android.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.SheetState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.shieldai.android.ui.theme.BrandPrimary
|
||||
|
||||
data class ModalAction(
|
||||
val text: String,
|
||||
val onClick: () -> Unit,
|
||||
val isPrimary: Boolean = false
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ShieldBottomSheet(
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||
title: String? = null,
|
||||
actions: List<ModalAction> = emptyList(),
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(bottom = 32.dp)
|
||||
) {
|
||||
if (title != null) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Start
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
content()
|
||||
if (actions.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
actions.forEach { action ->
|
||||
TextButton(
|
||||
onClick = action.onClick,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = if (action.isPrimary) {
|
||||
ButtonDefaults.textButtonColors(contentColor = BrandPrimary)
|
||||
} else {
|
||||
ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
) {
|
||||
Text(text = action.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ShieldAlertDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
title: String,
|
||||
message: String,
|
||||
confirmText: String = "Confirm",
|
||||
dismissText: String = "Cancel",
|
||||
isDestructive: Boolean = false
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
modifier = modifier,
|
||||
title = {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onConfirm) {
|
||||
Text(
|
||||
text = confirmText,
|
||||
color = if (isDestructive) MaterialTheme.colorScheme.error else BrandPrimary
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(text = dismissText)
|
||||
}
|
||||
},
|
||||
shape = MaterialTheme.shapes.large
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.shieldai.android.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.shieldai.android.ui.theme.BrandAccent
|
||||
import com.shieldai.android.ui.theme.BrandPrimary
|
||||
import com.shieldai.android.ui.theme.Error
|
||||
import com.shieldai.android.ui.theme.OutlineLight
|
||||
import com.shieldai.android.ui.theme.Success
|
||||
import com.shieldai.android.ui.theme.Warning
|
||||
|
||||
enum class ProgressColor {
|
||||
Primary, Accent, Success, Warning, Error
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ShieldProgressBar(
|
||||
progress: Float,
|
||||
modifier: Modifier = Modifier,
|
||||
color: ProgressColor = ProgressColor.Primary,
|
||||
showPercentage: Boolean = false
|
||||
) {
|
||||
val progressColor = when (color) {
|
||||
ProgressColor.Primary -> BrandPrimary
|
||||
ProgressColor.Accent -> BrandAccent
|
||||
ProgressColor.Success -> Success
|
||||
ProgressColor.Warning -> Warning
|
||||
ProgressColor.Error -> Error
|
||||
}
|
||||
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
LinearProgressIndicator(
|
||||
progress = { progress.coerceIn(0f, 1f) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(8.dp)
|
||||
.clip(RoundedCornerShape(4.dp)),
|
||||
color = progressColor,
|
||||
trackColor = OutlineLight,
|
||||
strokeCap = StrokeCap.Round
|
||||
)
|
||||
if (showPercentage) {
|
||||
Text(
|
||||
text = "${(progress * 100).toInt()}%",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 4.dp),
|
||||
textAlign = TextAlign.End
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package com.shieldai.android.ui.components
|
||||
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.shieldai.android.ui.theme.OutlineLight
|
||||
|
||||
@Composable
|
||||
fun ShieldSkeletonLine(
|
||||
modifier: Modifier = Modifier,
|
||||
widthFraction: Float = 1f
|
||||
) {
|
||||
val shimmerColors = listOf(
|
||||
OutlineLight.copy(alpha = 0.6f),
|
||||
Color.White.copy(alpha = 0.4f),
|
||||
OutlineLight.copy(alpha = 0.6f)
|
||||
)
|
||||
|
||||
val transition = rememberInfiniteTransition(label = "shimmer")
|
||||
val translateAnimation by transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 1000f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(durationMillis = 1200, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Restart
|
||||
),
|
||||
label = "shimmerOffset"
|
||||
)
|
||||
|
||||
val brush = Brush.linearGradient(
|
||||
colors = shimmerColors,
|
||||
start = Offset(translateAnimation - 200f, 0f),
|
||||
end = Offset(translateAnimation, 0f)
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth(fraction = widthFraction)
|
||||
.height(14.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(brush)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ShieldSkeletonRectangle(
|
||||
modifier: Modifier = Modifier,
|
||||
height: Int = 100
|
||||
) {
|
||||
val shimmerColors = listOf(
|
||||
OutlineLight.copy(alpha = 0.6f),
|
||||
Color.White.copy(alpha = 0.4f),
|
||||
OutlineLight.copy(alpha = 0.6f)
|
||||
)
|
||||
|
||||
val transition = rememberInfiniteTransition(label = "shimmerRect")
|
||||
val translateAnimation by transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 1000f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(durationMillis = 1200, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Restart
|
||||
),
|
||||
label = "shimmerRectOffset"
|
||||
)
|
||||
|
||||
val brush = Brush.linearGradient(
|
||||
colors = shimmerColors,
|
||||
start = Offset(translateAnimation - 200f, 0f),
|
||||
end = Offset(translateAnimation, 0f)
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(height.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(brush)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ShieldSkeletonCard(
|
||||
modifier: Modifier = Modifier,
|
||||
lines: Int = 3
|
||||
) {
|
||||
Column(modifier = modifier.padding(16.dp)) {
|
||||
ShieldSkeletonRectangle(height = 120)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
repeat(lines) { index ->
|
||||
ShieldSkeletonLine(
|
||||
widthFraction = when (index) {
|
||||
0 -> 0.9f
|
||||
lines - 1 -> 0.5f
|
||||
else -> 0.75f
|
||||
}
|
||||
)
|
||||
if (index < lines - 1) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.shieldai.android.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.shieldai.android.ui.theme.Error
|
||||
|
||||
enum class InputType {
|
||||
Text, Email, Password, Number, Phone
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ShieldTextField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
label: String,
|
||||
modifier: Modifier = Modifier,
|
||||
placeholder: String = "",
|
||||
inputType: InputType = InputType.Text,
|
||||
isError: Boolean = false,
|
||||
errorMessage: String? = null,
|
||||
helperText: String? = null,
|
||||
enabled: Boolean = true,
|
||||
readOnly: Boolean = false
|
||||
) {
|
||||
var passwordVisible by remember { mutableStateOf(false) }
|
||||
|
||||
val keyboardType = when (inputType) {
|
||||
InputType.Email -> KeyboardType.Email
|
||||
InputType.Password -> KeyboardType.Password
|
||||
InputType.Number -> KeyboardType.Number
|
||||
InputType.Phone -> KeyboardType.Phone
|
||||
else -> KeyboardType.Text
|
||||
}
|
||||
|
||||
val visualTransformation = if (inputType == InputType.Password && !passwordVisible) {
|
||||
PasswordVisualTransformation()
|
||||
} else {
|
||||
VisualTransformation.None
|
||||
}
|
||||
|
||||
val trailingIcon = if (inputType == InputType.Password) {
|
||||
@Composable {
|
||||
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||
Text(
|
||||
text = if (passwordVisible) "Hide" else "Show",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
} else null
|
||||
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
label = { Text(label) },
|
||||
placeholder = if (placeholder.isNotEmpty()) {{ Text(placeholder) }} else null,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = enabled,
|
||||
readOnly = readOnly,
|
||||
singleLine = true,
|
||||
isError = isError,
|
||||
visualTransformation = visualTransformation,
|
||||
trailingIcon = trailingIcon,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = keyboardType,
|
||||
imeAction = ImeAction.Next
|
||||
),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = MaterialTheme.colorScheme.surface,
|
||||
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
|
||||
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
focusedIndicatorColor = MaterialTheme.colorScheme.primary,
|
||||
unfocusedIndicatorColor = MaterialTheme.colorScheme.outline,
|
||||
errorIndicatorColor = Error,
|
||||
focusedLabelColor = MaterialTheme.colorScheme.primary,
|
||||
unfocusedLabelColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
cursorColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
)
|
||||
|
||||
if (isError && errorMessage != null) {
|
||||
Text(
|
||||
text = errorMessage,
|
||||
color = Error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(start = 16.dp, top = 4.dp)
|
||||
)
|
||||
} else if (helperText != null) {
|
||||
Text(
|
||||
text = helperText,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(start = 16.dp, top = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.shieldai.android.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Snackbar
|
||||
import androidx.compose.material3.SnackbarData
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarVisuals
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.shieldai.android.ui.theme.Error
|
||||
import com.shieldai.android.ui.theme.Info
|
||||
import com.shieldai.android.ui.theme.Success
|
||||
import com.shieldai.android.ui.theme.TextPrimaryDark
|
||||
import com.shieldai.android.ui.theme.Warning
|
||||
|
||||
enum class ToastVariant {
|
||||
Success, Error, Warning, Info
|
||||
}
|
||||
|
||||
data class ToastColors(
|
||||
val container: Color,
|
||||
val content: Color,
|
||||
val action: Color
|
||||
)
|
||||
|
||||
fun toastColors(variant: ToastVariant): ToastColors = when (variant) {
|
||||
ToastVariant.Success -> ToastColors(
|
||||
container = Success,
|
||||
content = TextPrimaryDark,
|
||||
action = TextPrimaryDark
|
||||
)
|
||||
ToastVariant.Error -> ToastColors(
|
||||
container = Error,
|
||||
content = TextPrimaryDark,
|
||||
action = TextPrimaryDark
|
||||
)
|
||||
ToastVariant.Warning -> ToastColors(
|
||||
container = Warning,
|
||||
content = TextPrimaryDark,
|
||||
action = TextPrimaryDark
|
||||
)
|
||||
ToastVariant.Info -> ToastColors(
|
||||
container = Info,
|
||||
content = TextPrimaryDark,
|
||||
action = TextPrimaryDark
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ShieldToastHost(
|
||||
hostState: SnackbarHostState,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
SnackbarHost(
|
||||
hostState = hostState,
|
||||
modifier = modifier,
|
||||
snackbar = { data: SnackbarData ->
|
||||
val visuals = data.visuals as? ShieldSnackbarVisuals
|
||||
val colors = visuals?.let { toastColors(it.variant) }
|
||||
?: toastColors(ToastVariant.Info)
|
||||
|
||||
Snackbar(
|
||||
snackbarData = data,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
containerColor = colors.container,
|
||||
contentColor = colors.content,
|
||||
actionColor = colors.action
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
class ShieldSnackbarVisuals(
|
||||
message: String,
|
||||
actionLabel: String? = null,
|
||||
duration: SnackbarDuration = SnackbarDuration.Short,
|
||||
val variant: ToastVariant = ToastVariant.Info
|
||||
) : SnackbarVisuals {
|
||||
override val message: String = message
|
||||
override val actionLabel: String? = actionLabel
|
||||
override val duration: SnackbarDuration = duration
|
||||
override val withDismissAction: Boolean = false
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.shieldai.android.ui.screens.auth
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.TabRow
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.shieldai.android.R
|
||||
import com.shieldai.android.ui.components.ShieldCard
|
||||
import com.shieldai.android.viewmodel.AuthViewModel
|
||||
|
||||
@Composable
|
||||
fun AuthScreen(
|
||||
viewModel: AuthViewModel,
|
||||
onNavigateToForgotPassword: () -> Unit,
|
||||
onNavigateToResetPassword: () -> Unit
|
||||
) {
|
||||
var selectedTab by remember { mutableIntStateOf(0) }
|
||||
val tabs = listOf("Login", "Sign Up")
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(64.dp))
|
||||
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.ic_launcher_foreground),
|
||||
contentDescription = "ShieldAI Logo",
|
||||
modifier = Modifier.size(72.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "ShieldAI",
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "Your all-in-one digital protection",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
TabRow(
|
||||
selectedTabIndex = selectedTab,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
tabs.forEachIndexed { index, title ->
|
||||
Tab(
|
||||
selected = selectedTab == index,
|
||||
onClick = { selectedTab = index },
|
||||
text = { Text(title) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
ShieldCard(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
if (selectedTab == 0) {
|
||||
LoginScreen(
|
||||
viewModel = viewModel,
|
||||
onNavigateToForgotPassword = onNavigateToForgotPassword,
|
||||
uiState = uiState
|
||||
)
|
||||
} else {
|
||||
SignupScreen(
|
||||
viewModel = viewModel,
|
||||
uiState = uiState
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.shieldai.android.ui.screens.auth
|
||||
|
||||
import android.content.Context
|
||||
import android.security.identity.IdentityCredentialException
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
|
||||
@Composable
|
||||
fun BiometricAuthScreen(
|
||||
onAuthenticated: () -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
title: String = "Biometric Authentication",
|
||||
subtitle: String = "Authenticate to access ShieldAI",
|
||||
description: String = "Use your fingerprint or face to sign in"
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val activity = context as? FragmentActivity
|
||||
|
||||
val biometricManager = remember {
|
||||
BiometricManager.from(context)
|
||||
}
|
||||
|
||||
val authenticators = BiometricManager.Authenticators.BIOMETRIC_STRONG or
|
||||
BiometricManager.Authenticators.BIOMETRIC_WEAK or
|
||||
BiometricManager.Authenticators.DEVICE_CREDENTIAL
|
||||
|
||||
val canAuthenticate = biometricManager.canAuthenticate(authenticators)
|
||||
|
||||
val promptInfo = remember {
|
||||
BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle(title)
|
||||
.setSubtitle(subtitle)
|
||||
.setDescription(description)
|
||||
.setAllowedAuthenticators(authenticators)
|
||||
.build()
|
||||
}
|
||||
|
||||
DisposableEffect(activity) {
|
||||
if (activity != null && canAuthenticate == BiometricManager.BIOMETRIC_SUCCESS) {
|
||||
val biometricPrompt = BiometricPrompt(
|
||||
activity,
|
||||
object : BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
onAuthenticated()
|
||||
}
|
||||
|
||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||
if (errorCode != BiometricPrompt.ERROR_NEGATIVE_BUTTON &&
|
||||
errorCode != BiometricPrompt.ERROR_USER_CANCELED
|
||||
) {
|
||||
onError(errString.toString())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAuthenticationFailed() {
|
||||
onError("Authentication failed")
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
biometricPrompt.authenticate(promptInfo)
|
||||
}
|
||||
|
||||
onDispose { }
|
||||
}
|
||||
}
|
||||
|
||||
fun canUseBiometric(context: Context): Boolean {
|
||||
val biometricManager = BiometricManager.from(context)
|
||||
val authenticators = BiometricManager.Authenticators.BIOMETRIC_STRONG or
|
||||
BiometricManager.Authenticators.BIOMETRIC_WEAK or
|
||||
BiometricManager.Authenticators.DEVICE_CREDENTIAL
|
||||
return biometricManager.canAuthenticate(authenticators) == BiometricManager.BIOMETRIC_SUCCESS
|
||||
}
|
||||
|
||||
fun isBiometricEnabled(context: Context): Boolean {
|
||||
val prefs = context.getSharedPreferences("shieldai_biometric_prefs", Context.MODE_PRIVATE)
|
||||
return prefs.getBoolean("biometric_enabled", false)
|
||||
}
|
||||
|
||||
fun setBiometricEnabled(context: Context, enabled: Boolean) {
|
||||
val prefs = context.getSharedPreferences("shieldai_biometric_prefs", Context.MODE_PRIVATE)
|
||||
prefs.edit().putBoolean("biometric_enabled", enabled).apply()
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package com.shieldai.android.ui.screens.auth
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.shieldai.android.ui.components.InputType
|
||||
import com.shieldai.android.ui.components.ShieldButton
|
||||
import com.shieldai.android.ui.components.ShieldButtonVariant
|
||||
import com.shieldai.android.ui.components.ShieldCard
|
||||
import com.shieldai.android.ui.components.ShieldTextField
|
||||
import com.shieldai.android.viewmodel.AuthViewModel
|
||||
|
||||
@Composable
|
||||
fun ForgotPasswordScreen(
|
||||
viewModel: AuthViewModel,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
var email by remember { mutableStateOf("") }
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
|
||||
if (uiState.forgotPasswordSent) {
|
||||
ShieldCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = "Check Your Email",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = "We've sent password reset instructions to $email",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
ShieldButton(
|
||||
text = "Back to Login",
|
||||
onClick = onBack,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
fullWidth = true
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ShieldCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Reset Password",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Enter your email address and we'll send you instructions to reset your password.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
ShieldTextField(
|
||||
value = email,
|
||||
onValueChange = { email = it },
|
||||
label = "Email",
|
||||
inputType = InputType.Email,
|
||||
placeholder = "you@example.com"
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
ShieldButton(
|
||||
text = "Send Reset Instructions",
|
||||
onClick = { viewModel.forgotPassword(email) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
loading = uiState.isLoading,
|
||||
enabled = email.isNotBlank(),
|
||||
fullWidth = true
|
||||
)
|
||||
if (uiState.error != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = uiState.error!!,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
ShieldButton(
|
||||
text = "Back to Login",
|
||||
onClick = onBack,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
variant = ShieldButtonVariant.Ghost,
|
||||
fullWidth = true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package com.shieldai.android.ui.screens.auth
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.android.gms.auth.api.signin.GoogleSignIn
|
||||
import com.google.android.gms.auth.api.signin.GoogleSignInClient
|
||||
import com.google.android.gms.auth.api.signin.GoogleSignInOptions
|
||||
import com.google.android.gms.common.api.ApiException
|
||||
import com.shieldai.android.ui.components.InputType
|
||||
import com.shieldai.android.ui.components.ShieldButton
|
||||
import com.shieldai.android.ui.components.ShieldTextField
|
||||
import com.shieldai.android.ui.theme.BrandPrimary
|
||||
import com.shieldai.android.viewmodel.AuthUiState
|
||||
import com.shieldai.android.viewmodel.AuthViewModel
|
||||
|
||||
@Composable
|
||||
fun LoginScreen(
|
||||
viewModel: AuthViewModel,
|
||||
onNavigateToForgotPassword: () -> Unit,
|
||||
uiState: AuthUiState
|
||||
) {
|
||||
var email by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var rememberMe by remember { mutableStateOf(false) }
|
||||
val context = LocalContext.current
|
||||
|
||||
val gso = remember {
|
||||
GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
|
||||
.requestIdToken(context.getString(com.shieldai.android.R.string.default_web_client_id))
|
||||
.requestEmail()
|
||||
.build()
|
||||
}
|
||||
val googleSignInClient: GoogleSignInClient = remember {
|
||||
GoogleSignIn.getClient(context, gso)
|
||||
}
|
||||
|
||||
val googleSignInLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
val data = result.data
|
||||
val task = GoogleSignIn.getSignedInAccountFromIntent(data)
|
||||
try {
|
||||
val account = task.getResult(ApiException::class.java)
|
||||
account.idToken?.let { token ->
|
||||
viewModel.signInWithGoogle(token)
|
||||
}
|
||||
} catch (_: ApiException) { }
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
ShieldTextField(
|
||||
value = email,
|
||||
onValueChange = { email = it },
|
||||
label = "Email",
|
||||
inputType = InputType.Email,
|
||||
placeholder = "you@example.com"
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
ShieldTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = "Password",
|
||||
inputType = InputType.Password,
|
||||
placeholder = "Enter your password"
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Switch(
|
||||
checked = rememberMe,
|
||||
onCheckedChange = { rememberMe = it }
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "Remember me",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
|
||||
TextButton(onClick = onNavigateToForgotPassword) {
|
||||
Text(
|
||||
text = "Forgot password?",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = BrandPrimary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
ShieldButton(
|
||||
text = "Sign In",
|
||||
onClick = { viewModel.login(email, password) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
loading = uiState.isLoading,
|
||||
fullWidth = true
|
||||
)
|
||||
|
||||
if (uiState.error != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = uiState.error!!,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "or continue with",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
val signInIntent = googleSignInClient.signInIntent
|
||||
googleSignInLauncher.launch(signInIntent)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.onSurface)
|
||||
) {
|
||||
Text(
|
||||
text = "Sign in with Google",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package com.shieldai.android.ui.screens.auth
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.shieldai.android.ui.components.InputType
|
||||
import com.shieldai.android.ui.components.ShieldButton
|
||||
import com.shieldai.android.ui.components.ShieldCard
|
||||
import com.shieldai.android.ui.components.ShieldTextField
|
||||
import com.shieldai.android.viewmodel.AuthViewModel
|
||||
|
||||
@Composable
|
||||
fun ResetPasswordScreen(
|
||||
viewModel: AuthViewModel,
|
||||
email: String,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
var code by remember { mutableStateOf("") }
|
||||
var newPassword by remember { mutableStateOf("") }
|
||||
var confirmPassword by remember { mutableStateOf("") }
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
|
||||
if (uiState.resetPasswordSuccess) {
|
||||
ShieldCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = "Password Reset Successful",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = "Your password has been reset. You can now log in with your new password.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
ShieldButton(
|
||||
text = "Back to Login",
|
||||
onClick = onBack,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
fullWidth = true
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ShieldCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Set New Password",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Enter the reset code sent to your email and your new password.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
ShieldTextField(
|
||||
value = code,
|
||||
onValueChange = { code = it },
|
||||
label = "Reset Code",
|
||||
placeholder = "Enter the code from email"
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
ShieldTextField(
|
||||
value = newPassword,
|
||||
onValueChange = { newPassword = it },
|
||||
label = "New Password",
|
||||
inputType = InputType.Password,
|
||||
placeholder = "Enter new password"
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
ShieldTextField(
|
||||
value = confirmPassword,
|
||||
onValueChange = { confirmPassword = it },
|
||||
label = "Confirm New Password",
|
||||
inputType = InputType.Password,
|
||||
placeholder = "Re-enter new password",
|
||||
isError = confirmPassword.isNotEmpty() && newPassword != confirmPassword,
|
||||
errorMessage = if (confirmPassword.isNotEmpty() && newPassword != confirmPassword) "Passwords do not match" else null
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
ShieldButton(
|
||||
text = "Reset Password",
|
||||
onClick = { viewModel.resetPassword(email, code, newPassword) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
loading = uiState.isLoading,
|
||||
enabled = code.isNotBlank() && newPassword.isNotBlank()
|
||||
&& newPassword == confirmPassword,
|
||||
fullWidth = true
|
||||
)
|
||||
if (uiState.error != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = uiState.error!!,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package com.shieldai.android.ui.screens.auth
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.shieldai.android.ui.components.InputType
|
||||
import com.shieldai.android.ui.components.ProgressColor
|
||||
import com.shieldai.android.ui.components.ShieldButton
|
||||
import com.shieldai.android.ui.components.ShieldProgressBar
|
||||
import com.shieldai.android.ui.components.ShieldTextField
|
||||
import com.shieldai.android.util.PasswordStrength
|
||||
import com.shieldai.android.util.calculatePasswordStrength
|
||||
import com.shieldai.android.util.passwordStrengthLabel
|
||||
import com.shieldai.android.viewmodel.AuthUiState
|
||||
import com.shieldai.android.viewmodel.AuthViewModel
|
||||
|
||||
@Composable
|
||||
fun SignupScreen(
|
||||
viewModel: AuthViewModel,
|
||||
uiState: AuthUiState
|
||||
) {
|
||||
var name by remember { mutableStateOf("") }
|
||||
var email by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var confirmPassword by remember { mutableStateOf("") }
|
||||
var acceptTerms by remember { mutableStateOf(false) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
ShieldTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = "Full Name",
|
||||
placeholder = "John Doe"
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
ShieldTextField(
|
||||
value = email,
|
||||
onValueChange = { email = it },
|
||||
label = "Email",
|
||||
inputType = InputType.Email,
|
||||
placeholder = "you@example.com"
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
ShieldTextField(
|
||||
value = password,
|
||||
onValueChange = {
|
||||
password = it
|
||||
viewModel.updatePasswordStrength(it)
|
||||
},
|
||||
label = "Password",
|
||||
inputType = InputType.Password,
|
||||
placeholder = "Create a strong password"
|
||||
)
|
||||
|
||||
if (password.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
val strength = calculatePasswordStrength(password)
|
||||
ShieldProgressBar(
|
||||
progress = when (strength) {
|
||||
PasswordStrength.WEAK -> 0.25f
|
||||
PasswordStrength.FAIR -> 0.5f
|
||||
PasswordStrength.STRONG -> 0.75f
|
||||
PasswordStrength.VERY_STRONG -> 1.0f
|
||||
},
|
||||
color = when (strength) {
|
||||
PasswordStrength.WEAK -> ProgressColor.Error
|
||||
PasswordStrength.FAIR -> ProgressColor.Warning
|
||||
PasswordStrength.STRONG -> ProgressColor.Success
|
||||
PasswordStrength.VERY_STRONG -> ProgressColor.Success
|
||||
},
|
||||
showPercentage = false
|
||||
)
|
||||
Text(
|
||||
text = "Password strength: ${passwordStrengthLabel(strength)}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
ShieldTextField(
|
||||
value = confirmPassword,
|
||||
onValueChange = { confirmPassword = it },
|
||||
label = "Confirm Password",
|
||||
inputType = InputType.Password,
|
||||
placeholder = "Re-enter your password",
|
||||
isError = confirmPassword.isNotEmpty() && password != confirmPassword,
|
||||
errorMessage = if (confirmPassword.isNotEmpty() && password != confirmPassword) "Passwords do not match" else null
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(
|
||||
checked = acceptTerms,
|
||||
onCheckedChange = { acceptTerms = it }
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "I accept the Terms of Service and Privacy Policy",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
ShieldButton(
|
||||
text = "Create Account",
|
||||
onClick = { viewModel.signup(name, email, password) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
loading = uiState.isLoading,
|
||||
enabled = name.isNotBlank() && email.isNotBlank() && password.isNotBlank()
|
||||
&& password == confirmPassword && acceptTerms,
|
||||
fullWidth = true
|
||||
)
|
||||
|
||||
if (uiState.error != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = uiState.error!!,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package com.shieldai.android.ui.screens.onboarding
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.shieldai.android.ui.components.ShieldButton
|
||||
import com.shieldai.android.ui.theme.BrandPrimary
|
||||
import com.shieldai.android.ui.theme.Success
|
||||
|
||||
@Composable
|
||||
fun CompleteStep(onComplete: () -> Unit) {
|
||||
val animatedProgress = remember { Animatable(0f) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
animatedProgress.animateTo(
|
||||
targetValue = 1f,
|
||||
animationSpec = tween(durationMillis = 1000)
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = androidx.compose.foundation.layout.Arrangement.Center
|
||||
) {
|
||||
Canvas(
|
||||
modifier = Modifier.size(120.dp)
|
||||
) {
|
||||
val strokeWidth = 8.dp.toPx()
|
||||
val radius = (size.minDimension - strokeWidth) / 2
|
||||
val center = Offset(size.width / 2, size.height / 2)
|
||||
|
||||
drawCircle(
|
||||
color = Color.LightGray.copy(alpha = 0.3f),
|
||||
radius = radius,
|
||||
center = center,
|
||||
style = Stroke(width = strokeWidth, cap = StrokeCap.Round)
|
||||
)
|
||||
|
||||
drawArc(
|
||||
color = Success,
|
||||
startAngle = -90f,
|
||||
sweepAngle = 360f * animatedProgress.value,
|
||||
useCenter = false,
|
||||
topLeft = Offset(center.x - radius, center.y - radius),
|
||||
size = androidx.compose.ui.geometry.Size(radius * 2, radius * 2),
|
||||
style = Stroke(width = strokeWidth, cap = StrokeCap.Round)
|
||||
)
|
||||
|
||||
if (animatedProgress.value >= 0.5f) {
|
||||
val checkProgress = (animatedProgress.value - 0.5f) * 2f
|
||||
val startX = center.x - radius * 0.35f
|
||||
val midX = center.x - radius * 0.05f
|
||||
val endX = center.x + radius * 0.5f
|
||||
val startY = center.y
|
||||
val midY = center.y + radius * 0.35f * checkProgress
|
||||
val endY = center.y - radius * 0.3f * checkProgress
|
||||
|
||||
drawLine(
|
||||
color = Success,
|
||||
start = Offset(startX, startY),
|
||||
end = Offset(midX, midY.coerceAtMost(startY + radius * 0.35f)),
|
||||
strokeWidth = 6.dp.toPx(),
|
||||
cap = StrokeCap.Round
|
||||
)
|
||||
|
||||
if (animatedProgress.value >= 0.75f) {
|
||||
val endCheckProgress = (animatedProgress.value - 0.75f) * 4f
|
||||
drawLine(
|
||||
color = Success,
|
||||
start = Offset(midX, midY.coerceAtMost(startY + radius * 0.35f)),
|
||||
end = Offset(
|
||||
startX + (endX - startX) * endCheckProgress * 0.5f + radius * 0.5f * endCheckProgress,
|
||||
endY.coerceAtLeast(startY - radius * 0.3f * endCheckProgress)
|
||||
),
|
||||
strokeWidth = 6.dp.toPx(),
|
||||
cap = StrokeCap.Round
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
Text(
|
||||
text = "You're All Set!",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Text(
|
||||
text = "Your account is ready. Start monitoring your digital footprint and stay protected.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(40.dp))
|
||||
|
||||
ShieldButton(
|
||||
text = "Get Started",
|
||||
onClick = onComplete,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
fullWidth = true
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package com.shieldai.android.ui.screens.onboarding
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.shieldai.android.ui.components.InputType
|
||||
import com.shieldai.android.ui.components.ShieldButton
|
||||
import com.shieldai.android.ui.components.ShieldButtonVariant
|
||||
import com.shieldai.android.ui.components.ShieldTextField
|
||||
|
||||
@Composable
|
||||
fun FamilyInviteStep(
|
||||
invites: List<String>,
|
||||
onAddInvite: (String) -> Unit,
|
||||
onRemoveInvite: (Int) -> Unit
|
||||
) {
|
||||
var emailInput by remember { mutableStateOf("") }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Text(
|
||||
text = "Invite Family Members",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "Protect your family too. Add their emails to include them in your plan.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
ShieldTextField(
|
||||
value = emailInput,
|
||||
onValueChange = { emailInput = it },
|
||||
label = "Family Email",
|
||||
inputType = InputType.Email,
|
||||
placeholder = "family@example.com",
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
ShieldButton(
|
||||
text = "Invite",
|
||||
onClick = {
|
||||
if (emailInput.isNotBlank()) {
|
||||
onAddInvite(emailInput.trim())
|
||||
emailInput = ""
|
||||
}
|
||||
},
|
||||
variant = ShieldButtonVariant.Primary,
|
||||
enabled = emailInput.isNotBlank(),
|
||||
modifier = Modifier.padding(top = 6.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
if (invites.isEmpty()) {
|
||||
Text(
|
||||
text = "No invites sent yet. You can skip this step and invite later.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp)
|
||||
)
|
||||
} else {
|
||||
invites.forEachIndexed { index, email ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = email,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
IconButton(onClick = { onRemoveInvite(index) }) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Remove",
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Text(
|
||||
text = "You can always invite more family members later from Settings.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package com.shieldai.android.ui.screens.onboarding
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.shieldai.android.ui.theme.BrandPrimary
|
||||
import com.shieldai.android.viewmodel.AuthViewModel
|
||||
|
||||
@Composable
|
||||
fun OnboardingScreen(
|
||||
viewModel: AuthViewModel,
|
||||
onComplete: () -> Unit
|
||||
) {
|
||||
val pagerState = rememberPagerState(pageCount = { 4 })
|
||||
val onboardingData by viewModel.onboardingData.collectAsState()
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
) { page ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
when (page) {
|
||||
0 -> PlanSelectionStep(
|
||||
selectedPlan = onboardingData.selectedPlan,
|
||||
onPlanSelected = { plan ->
|
||||
viewModel.updateOnboardingData {
|
||||
it.copy(selectedPlan = plan)
|
||||
}
|
||||
}
|
||||
)
|
||||
1 -> WatchlistSetupStep(
|
||||
watchlistItems = onboardingData.watchlistItems,
|
||||
onAddItem = { item ->
|
||||
viewModel.updateOnboardingData {
|
||||
it.copy(watchlistItems = it.watchlistItems + item)
|
||||
}
|
||||
},
|
||||
onRemoveItem = { index ->
|
||||
viewModel.updateOnboardingData {
|
||||
it.copy(watchlistItems = it.watchlistItems.toMutableList().apply { removeAt(index) })
|
||||
}
|
||||
}
|
||||
)
|
||||
2 -> FamilyInviteStep(
|
||||
invites = onboardingData.familyInvites,
|
||||
onAddInvite = { email ->
|
||||
viewModel.updateOnboardingData {
|
||||
it.copy(familyInvites = it.familyInvites + email)
|
||||
}
|
||||
},
|
||||
onRemoveInvite = { index ->
|
||||
viewModel.updateOnboardingData {
|
||||
it.copy(familyInvites = it.familyInvites.toMutableList().apply { removeAt(index) })
|
||||
}
|
||||
}
|
||||
)
|
||||
3 -> CompleteStep(onComplete = onComplete)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
repeat(4) { index ->
|
||||
val isSelected = pagerState.currentPage == index
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 4.dp)
|
||||
.size(if (isSelected) 10.dp else 8.dp)
|
||||
.clip(CircleShape)
|
||||
.drawBehind {
|
||||
drawCircle(
|
||||
color = if (isSelected) BrandPrimary
|
||||
else Color.Gray.copy(alpha = 0.3f)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package com.shieldai.android.ui.screens.onboarding
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.RadioButtonDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.shieldai.android.ui.theme.BrandPrimary
|
||||
|
||||
data class Plan(
|
||||
val name: String,
|
||||
val price: String,
|
||||
val features: List<String>,
|
||||
val description: String
|
||||
)
|
||||
|
||||
private val plans = listOf(
|
||||
Plan(
|
||||
name = "Basic",
|
||||
price = "Free",
|
||||
features = listOf(
|
||||
"Monitor 1 email/phone",
|
||||
"Basic alerts",
|
||||
"7-day data history"
|
||||
),
|
||||
description = "Essential protection"
|
||||
),
|
||||
Plan(
|
||||
name = "Plus",
|
||||
price = "$9.99/mo",
|
||||
features = listOf(
|
||||
"Monitor up to 5 emails/phones",
|
||||
"Real-time alerts",
|
||||
"30-day data history",
|
||||
"Family sharing (2 members)"
|
||||
),
|
||||
description = "Enhanced protection"
|
||||
),
|
||||
Plan(
|
||||
name = "Premium",
|
||||
price = "$19.99/mo",
|
||||
features = listOf(
|
||||
"Unlimited monitoring",
|
||||
"Priority alerts",
|
||||
"90-day data history",
|
||||
"Family sharing (5 members)",
|
||||
"Dark web monitoring",
|
||||
"Identity restoration support"
|
||||
),
|
||||
description = "Maximum protection"
|
||||
)
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun PlanSelectionStep(
|
||||
selectedPlan: String,
|
||||
onPlanSelected: (String) -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Text(
|
||||
text = "Choose Your Plan",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "Select the plan that fits your needs. You can upgrade or change anytime.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
plans.forEach { plan ->
|
||||
val isSelected = selectedPlan == plan.name
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 6.dp)
|
||||
.clickable { onPlanSelected(plan.name) },
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
border = BorderStroke(
|
||||
width = if (isSelected) 2.dp else 1.dp,
|
||||
color = if (isSelected) BrandPrimary else MaterialTheme.colorScheme.outline
|
||||
),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (isSelected) {
|
||||
BrandPrimary.copy(alpha = 0.08f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surface
|
||||
}
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
RadioButton(
|
||||
selected = isSelected,
|
||||
onClick = { onPlanSelected(plan.name) },
|
||||
colors = RadioButtonDefaults.colors(
|
||||
selectedColor = BrandPrimary
|
||||
)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = plan.name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Text(
|
||||
text = plan.price,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = BrandPrimary,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = plan.description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
plan.features.forEach { feature ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "•",
|
||||
color = BrandPrimary,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = feature,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package com.shieldai.android.ui.screens.onboarding
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.shieldai.android.ui.components.InputType
|
||||
import com.shieldai.android.ui.components.ShieldButton
|
||||
import com.shieldai.android.ui.components.ShieldButtonVariant
|
||||
import com.shieldai.android.ui.components.ShieldTextField
|
||||
|
||||
@Composable
|
||||
fun WatchlistSetupStep(
|
||||
watchlistItems: List<String>,
|
||||
onAddItem: (String) -> Unit,
|
||||
onRemoveItem: (Int) -> Unit
|
||||
) {
|
||||
var inputValue by remember { mutableStateOf("") }
|
||||
var inputType by remember { mutableStateOf(InputType.Email) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Text(
|
||||
text = "Set Up Your Watchlist",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "Add email addresses or phone numbers you want to monitor for breaches and leaks.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
ShieldTextField(
|
||||
value = inputValue,
|
||||
onValueChange = { inputValue = it },
|
||||
label = "Email or Phone",
|
||||
inputType = inputType,
|
||||
placeholder = "you@example.com or +1234567890",
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
ShieldButton(
|
||||
text = "Add",
|
||||
onClick = {
|
||||
if (inputValue.isNotBlank()) {
|
||||
onAddItem(inputValue.trim())
|
||||
inputValue = ""
|
||||
}
|
||||
},
|
||||
variant = ShieldButtonVariant.Primary,
|
||||
enabled = inputValue.isNotBlank(),
|
||||
modifier = Modifier.padding(top = 6.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
if (watchlistItems.isEmpty()) {
|
||||
Text(
|
||||
text = "No items added yet. Add emails or phones to start monitoring.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp)
|
||||
)
|
||||
} else {
|
||||
watchlistItems.forEachIndexed { index, item ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = item,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
IconButton(onClick = { onRemoveItem(index) }) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Remove",
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.shieldai.android.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val BrandPrimary = Color(0xFF4F46E5)
|
||||
val BrandPrimaryLight = Color(0xFF818CF8)
|
||||
val BrandPrimaryDark = Color(0xFF3730A3)
|
||||
|
||||
val BrandAccent = Color(0xFF06B6D4)
|
||||
val BrandAccentLight = Color(0xFF67E8F9)
|
||||
val BrandAccentDark = Color(0xFF0891B2)
|
||||
|
||||
val BgPrimaryLight = Color(0xFFFFFFFF)
|
||||
val BgSecondaryLight = Color(0xFFF8FAFC)
|
||||
val BgTertiaryLight = Color(0xFFF1F5F9)
|
||||
|
||||
val BgPrimaryDark = Color(0xFF0F172A)
|
||||
val BgSecondaryDark = Color(0xFF1E293B)
|
||||
val BgTertiaryDark = Color(0xFF334155)
|
||||
|
||||
val TextPrimaryLight = Color(0xFF0F172A)
|
||||
val TextSecondaryLight = Color(0xFF475569)
|
||||
val TextTertiaryLight = Color(0xFF94A3B8)
|
||||
|
||||
val TextPrimaryDark = Color(0xFFF1F5F9)
|
||||
val TextSecondaryDark = Color(0xFF94A3B8)
|
||||
val TextTertiaryDark = Color(0xFF64748B)
|
||||
|
||||
val Success = Color(0xFF22C55E)
|
||||
val Warning = Color(0xFFF59E0B)
|
||||
val Error = Color(0xFFEF4444)
|
||||
val Info = Color(0xFF3B82F6)
|
||||
|
||||
val SurfaceLight = Color(0xFFFFFFFF)
|
||||
val SurfaceDark = Color(0xFF1E293B)
|
||||
val OutlineLight = Color(0xFFE2E8F0)
|
||||
val OutlineDark = Color(0xFF475569)
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.shieldai.android.ui.theme
|
||||
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Shapes
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
val Shapes = Shapes(
|
||||
small = RoundedCornerShape(8.dp),
|
||||
medium = RoundedCornerShape(12.dp),
|
||||
large = RoundedCornerShape(16.dp)
|
||||
)
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.shieldai.android.ui.theme
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = BrandPrimary,
|
||||
onPrimary = BgPrimaryLight,
|
||||
primaryContainer = BrandPrimaryLight,
|
||||
onPrimaryContainer = BgPrimaryLight,
|
||||
secondary = BrandAccent,
|
||||
onSecondary = BgPrimaryLight,
|
||||
secondaryContainer = BrandAccentLight,
|
||||
onSecondaryContainer = BgPrimaryDark,
|
||||
tertiary = BrandPrimaryDark,
|
||||
onTertiary = BgPrimaryLight,
|
||||
background = BgPrimaryLight,
|
||||
onBackground = TextPrimaryLight,
|
||||
surface = SurfaceLight,
|
||||
onSurface = TextPrimaryLight,
|
||||
surfaceVariant = BgSecondaryLight,
|
||||
onSurfaceVariant = TextSecondaryLight,
|
||||
outline = OutlineLight,
|
||||
outlineVariant = BgTertiaryLight,
|
||||
error = Error,
|
||||
onError = BgPrimaryLight
|
||||
)
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = BrandPrimaryLight,
|
||||
onPrimary = BgPrimaryDark,
|
||||
primaryContainer = BrandPrimary,
|
||||
onPrimaryContainer = TextPrimaryDark,
|
||||
secondary = BrandAccentLight,
|
||||
onSecondary = BgPrimaryDark,
|
||||
secondaryContainer = BrandAccent,
|
||||
onSecondaryContainer = TextPrimaryDark,
|
||||
tertiary = BrandPrimaryDark,
|
||||
onTertiary = TextPrimaryDark,
|
||||
background = BgPrimaryDark,
|
||||
onBackground = TextPrimaryDark,
|
||||
surface = SurfaceDark,
|
||||
onSurface = TextPrimaryDark,
|
||||
surfaceVariant = BgSecondaryDark,
|
||||
onSurfaceVariant = TextSecondaryDark,
|
||||
outline = OutlineDark,
|
||||
outlineVariant = BgTertiaryDark,
|
||||
error = Error,
|
||||
onError = BgPrimaryDark
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun ShieldAITheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
dynamicColor: Boolean = false,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
shapes = Shapes,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.shieldai.android.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
val Typography = Typography(
|
||||
displayLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 57.sp,
|
||||
lineHeight = 64.sp,
|
||||
letterSpacing = (-0.25).sp
|
||||
),
|
||||
displayMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 45.sp,
|
||||
lineHeight = 52.sp
|
||||
),
|
||||
displaySmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 36.sp,
|
||||
lineHeight = 44.sp
|
||||
),
|
||||
headlineLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 32.sp,
|
||||
lineHeight = 40.sp
|
||||
),
|
||||
headlineMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 28.sp,
|
||||
lineHeight = 36.sp
|
||||
),
|
||||
headlineSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 24.sp,
|
||||
lineHeight = 32.sp
|
||||
),
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp
|
||||
),
|
||||
titleMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.15.sp
|
||||
),
|
||||
titleSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.1.sp
|
||||
),
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
),
|
||||
bodyMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.25.sp
|
||||
),
|
||||
bodySmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.4.sp
|
||||
),
|
||||
labelLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.1.sp
|
||||
),
|
||||
labelMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.shieldai.android.util
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.shieldai.android.ui.theme.Error
|
||||
import com.shieldai.android.ui.theme.Success
|
||||
import com.shieldai.android.ui.theme.Warning
|
||||
|
||||
enum class PasswordStrength {
|
||||
WEAK, FAIR, STRONG, VERY_STRONG
|
||||
}
|
||||
|
||||
fun calculatePasswordStrength(password: String): PasswordStrength {
|
||||
if (password.length < 6) return PasswordStrength.WEAK
|
||||
var score = 0
|
||||
if (password.length >= 8) score++
|
||||
if (password.length >= 12) score++
|
||||
if (password.any { it.isUpperCase() }) score++
|
||||
if (password.any { it.isLowerCase() }) score++
|
||||
if (password.any { it.isDigit() }) score++
|
||||
if (password.any { !it.isLetterOrDigit() }) score++
|
||||
return when {
|
||||
score <= 2 -> PasswordStrength.WEAK
|
||||
score == 3 -> PasswordStrength.FAIR
|
||||
score == 4 -> PasswordStrength.STRONG
|
||||
else -> PasswordStrength.VERY_STRONG
|
||||
}
|
||||
}
|
||||
|
||||
fun passwordStrengthProgress(strength: PasswordStrength): Float = when (strength) {
|
||||
PasswordStrength.WEAK -> 0.25f
|
||||
PasswordStrength.FAIR -> 0.5f
|
||||
PasswordStrength.STRONG -> 0.75f
|
||||
PasswordStrength.VERY_STRONG -> 1.0f
|
||||
}
|
||||
|
||||
fun passwordStrengthLabel(strength: PasswordStrength): String = when (strength) {
|
||||
PasswordStrength.WEAK -> "Weak"
|
||||
PasswordStrength.FAIR -> "Fair"
|
||||
PasswordStrength.STRONG -> "Strong"
|
||||
PasswordStrength.VERY_STRONG -> "Very Strong"
|
||||
}
|
||||
|
||||
fun passwordStrengthColor(strength: PasswordStrength): Color = when (strength) {
|
||||
PasswordStrength.WEAK -> Error
|
||||
PasswordStrength.FAIR -> Warning
|
||||
PasswordStrength.STRONG -> Success
|
||||
PasswordStrength.VERY_STRONG -> Success
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package com.shieldai.android.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.shieldai.android.ShieldAIApp
|
||||
import com.shieldai.android.data.repository.AuthRepository
|
||||
import com.shieldai.android.data.repository.AuthRepositoryImpl
|
||||
import com.shieldai.android.data.repository.User
|
||||
import com.shieldai.android.util.calculatePasswordStrength
|
||||
import com.shieldai.android.util.passwordStrengthProgress
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class AuthUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
val user: User? = null,
|
||||
val forgotPasswordSent: Boolean = false,
|
||||
val resetPasswordSuccess: Boolean = false,
|
||||
val passwordStrength: Float = 0f
|
||||
)
|
||||
|
||||
data class OnboardingData(
|
||||
val selectedPlan: String = "Basic",
|
||||
val watchlistItems: List<String> = emptyList(),
|
||||
val familyInvites: List<String> = emptyList()
|
||||
)
|
||||
|
||||
class AuthViewModel(
|
||||
private val repository: AuthRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(AuthUiState())
|
||||
val uiState: StateFlow<AuthUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val _isAuthenticated = MutableStateFlow(repository.isLoggedIn())
|
||||
val isAuthenticated: StateFlow<Boolean> = _isAuthenticated.asStateFlow()
|
||||
|
||||
private val _isNewUser = MutableStateFlow(false)
|
||||
val isNewUser: StateFlow<Boolean> = _isNewUser.asStateFlow()
|
||||
|
||||
private val _onboardingData = MutableStateFlow(OnboardingData())
|
||||
val onboardingData: StateFlow<OnboardingData> = _onboardingData.asStateFlow()
|
||||
|
||||
fun login(email: String, password: String) {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
|
||||
val result = repository.login(email, password)
|
||||
result.fold(
|
||||
onSuccess = { user ->
|
||||
_uiState.value = _uiState.value.copy(isLoading = false, user = user)
|
||||
_isAuthenticated.value = true
|
||||
_isNewUser.value = user.isNewUser
|
||||
},
|
||||
onFailure = { e ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = e.message ?: "Login failed"
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun signup(name: String, email: String, password: String) {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
|
||||
val result = repository.signup(name, email, password)
|
||||
result.fold(
|
||||
onSuccess = { user ->
|
||||
_uiState.value = _uiState.value.copy(isLoading = false, user = user)
|
||||
_isAuthenticated.value = true
|
||||
_isNewUser.value = user.isNewUser
|
||||
},
|
||||
onFailure = { e ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = e.message ?: "Signup failed"
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun forgotPassword(email: String) {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, error = null, forgotPasswordSent = false)
|
||||
val result = repository.forgotPassword(email)
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
_uiState.value = _uiState.value.copy(isLoading = false, forgotPasswordSent = true)
|
||||
},
|
||||
onFailure = { e ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = e.message ?: "Request failed"
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun resetPassword(email: String, code: String, password: String) {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, error = null, resetPasswordSuccess = false)
|
||||
val result = repository.resetPassword(email, code, password)
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
_uiState.value = _uiState.value.copy(isLoading = false, resetPasswordSuccess = true)
|
||||
},
|
||||
onFailure = { e ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = e.message ?: "Reset failed"
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun signInWithGoogle(idToken: String) {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
|
||||
val result = repository.signInWithGoogle(idToken)
|
||||
result.fold(
|
||||
onSuccess = { user ->
|
||||
_uiState.value = _uiState.value.copy(isLoading = false, user = user)
|
||||
_isAuthenticated.value = true
|
||||
_isNewUser.value = user.isNewUser
|
||||
},
|
||||
onFailure = { e ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = e.message ?: "Google Sign-In failed"
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
repository.clearTokens()
|
||||
_uiState.value = AuthUiState()
|
||||
_isAuthenticated.value = false
|
||||
_isNewUser.value = false
|
||||
_onboardingData.value = OnboardingData()
|
||||
}
|
||||
|
||||
fun updatePasswordStrength(password: String) {
|
||||
val strength = calculatePasswordStrength(password)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
passwordStrength = passwordStrengthProgress(strength)
|
||||
)
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_uiState.value = _uiState.value.copy(error = null)
|
||||
}
|
||||
|
||||
fun updateOnboardingData(update: (OnboardingData) -> OnboardingData) {
|
||||
_onboardingData.value = update(_onboardingData.value)
|
||||
}
|
||||
|
||||
fun completeOnboarding() {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
|
||||
val data = _onboardingData.value
|
||||
try {
|
||||
repository.saveToken(
|
||||
repository.getAccessToken() ?: throw Exception("Not authenticated"),
|
||||
repository.getRefreshToken()
|
||||
)
|
||||
_isNewUser.value = false
|
||||
_uiState.value = _uiState.value.copy(isLoading = false)
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = e.message ?: "Failed to complete onboarding"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val Factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
val app = ShieldAIApp.instance
|
||||
return AuthViewModel(app.authRepository) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user