From c9a945f29722de72be8a0d39e321092abfa2ee62 Mon Sep 17 00:00:00 2001 From: Niels Mokkenstorm Date: Fri, 03 Oct 2025 10:09:16 +0000 Subject: [PATCH] feat: add transaction filters --- package-lock.json | 13 ------------- app/app.json | 4 ++-- app/package-lock.json | 12 ++++++++++++ app/package.json | 1 + server/Dockerfile | 2 +- server/package-lock.json | 512 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------------------------------------------------------------------------------------------------------------------- server/package.json | 2 +- app/app/_layout.tsx | 4 +++- app/components/account-card.tsx | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ app/components/container-view.tsx | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ app/components/parallax-scroll-view.tsx | 60 ++++++++++++++++++++++++++++++++---------------------------- app/components/themed-text.tsx | 60 ------------------------------------------------------------ app/components/themed-view.tsx | 14 -------------- app/constants/theme.ts | 24 ++++++++++++++---------- app/hooks/use-account.ts | 14 ++++++++++++++ app/hooks/use-authentication.ts | 2 +- app/hooks/use-login.ts | 84 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------ app/hooks/use-rate-color.ts | 7 +++++++ app/hooks/use-theme-color.ts | 14 +++++--------- app/hooks/use-transactions.ts | 35 +++++++++++++++++++++++++++++++++++ app/providers/auth-provider.tsx | 20 -------------------- app/providers/query-client-provider.tsx | 11 ++++------- app/providers/token-provider.tsx | 8 +++++--- app/services/token-service.ts | 3 --- app/services/utils.ts | 18 ++++++++++++++++++ server/src/openapi.yaml | 103 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------- server/src/schema.ts | 49 +++++++++++++++++++++++++++++++++++++++++++------ server/src/seeder.ts | 32 ++++++++++++++++++-------------- server/src/server.ts | 169 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------------------------------------------------------------------------------- server/tests/api.spec.ts | 29 +++++++++++++++++++++++------ app/app/(auth)/index.tsx | 93 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------- app/app/(auth)/settings.tsx | 26 ++++++++++---------------- app/app/(tabs)/_layout.tsx | 46 ++++++++++++++++++++++++++++++---------------- app/app/(tabs)/account.tsx | 24 ++++++++++++++---------- app/app/(tabs)/cards.tsx | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ app/app/(tabs)/explore.tsx | 112 ---------------------------------------------------------------------------------------------------------------- app/app/(tabs)/index.tsx | 52 ++++++++++++++++------------------------------------ app/components/shared/theme-toggle.tsx | 28 ++++++++++++++++++++++++++++ app/components/theme/index.tsx | 6 ++++++ app/components/theme/themed-button.tsx | 44 ++++++++++++++++++++++++++++++++++++++++++++ app/components/theme/themed-error-box.tsx | 26 ++++++++++++++++++++++++++ app/components/theme/themed-error.tsx | 27 +++++++++++++++++++++++++++ app/components/theme/themed-text-input.tsx | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ app/components/theme/themed-text.tsx | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ app/components/theme/themed-view.tsx | 14 ++++++++++++++ app/components/transaction/transaction-card.tsx | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ app/components/transaction/transaction-filter.tsx | 141 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ app/components/transaction/transaction-list.tsx | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ app/components/ui/accountnumber.tsx | 7 ++++--- app/components/ui/collapsible.tsx | 31 +++++++++++++++++-------------- app/components/ui/icon-symbol.tsx | 2 ++ app/components/ui/price.tsx | 5 +++-- server/src/services/accounts.service.ts | 36 ++++++++++++++++++++++++++++++++++++ server/src/services/transactions.service.ts | 116 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ app/app/(tabs)/accounts/[accountId]/index.tsx | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 55 file(s) changed, 1973 insertion(s)(+), 682 deletion(s)(-) diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 --- a/package-lock.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "bankingmockapi", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "bankingmockapi", - "version": "1.0.0", - "license": "ISC" - } - } -} diff --git a/app/app.json b/app/app.json --- a/app/app.json +++ b/app/app.json @@ -33,9 +33,9 @@ "image": "./assets/images/splash-icon.png", "imageWidth": 200, "resizeMode": "contain", - "backgroundColor": "#ffffff", + "backgroundColor": "#EFF1F5", "dark": { - "backgroundColor": "#000000" + "backgroundColor": "#1E1E2E" } } ], diff --git a/app/package-lock.json b/app/package-lock.json --- a/app/package-lock.json +++ b/app/package-lock.json @@ -14,6 +14,7 @@ "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", + "@shopify/flash-list": "^2.1.0", "@tanstack/react-query": "^5.90.2", "expo": "~54.0.10", "expo-constants": "~18.0.9", @@ -3679,6 +3680,17 @@ "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", "dev": true, "license": "MIT" + }, + "node_modules/@shopify/flash-list": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@shopify/flash-list/-/flash-list-2.1.0.tgz", + "integrity": "sha512-/EIQlptG456yM5o9qNmNsmaZEFEOGvG3WGyb6GUAxSLlcKUGlPUkPI2NLW5wQSDEY4xSRa5zocUI+9xwmsM4Kg==", + "license": "MIT", + "peerDependencies": { + "@babel/runtime": "*", + "react": "*", + "react-native": "*" + } }, "node_modules/@sinclair/typebox": { "version": "0.27.8", diff --git a/app/package.json b/app/package.json --- a/app/package.json +++ b/app/package.json @@ -17,6 +17,7 @@ "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", + "@shopify/flash-list": "^2.1.0", "@tanstack/react-query": "^5.90.2", "expo": "~54.0.10", "expo-constants": "~18.0.9", diff --git a/server/Dockerfile b/server/Dockerfile --- a/server/Dockerfile +++ b/server/Dockerfile @@ -8,7 +8,7 @@ COPY . . -RUN ./node_modules/.bin/openapi --input ./src/openapi.yaml --output ./generated +RUN npm run generate USER node diff --git a/server/package-lock.json b/server/package-lock.json --- a/server/package-lock.json +++ b/server/package-lock.json @@ -12,7 +12,7 @@ "bcryptjs": "2.4.3", "body-parser": "^1.20.3", "cors": "^2.8.5", - "express": "4.21.2", + "express": "^5.1.0", "express-openapi-validator": "^5.6.0", "jsonwebtoken": "9.0.2", "sqlite-async": "ndp/sqlite-async#13-typescript", @@ -3244,14 +3244,44 @@ "optional": true }, "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", "license": "MIT", "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -3470,12 +3500,6 @@ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" - }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" }, "node_modules/asap": { "version": "2.0.6", @@ -4242,9 +4266,9 @@ "optional": true }, "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", + "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", "license": "MIT", "dependencies": { "safe-buffer": "5.2.1" @@ -4279,10 +4303,13 @@ } }, "node_modules/cookie-signature": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", - "license": "MIT" + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } }, "node_modules/cookiejar": { "version": "2.1.4", @@ -4795,45 +4822,41 @@ } }, "node_modules/express": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", - "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", + "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", "license": "MIT", "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "1.20.3", - "content-disposition": "0.5.4", - "content-type": "~1.0.4", - "cookie": "0.7.1", - "cookie-signature": "1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "1.3.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "0.1.12", - "proxy-addr": "~2.0.7", - "qs": "6.13.0", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "0.19.0", - "serve-static": "1.16.2", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" + "accepts": "^2.0.0", + "body-parser": "^2.2.0", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" }, "engines": { - "node": ">= 0.10.0" + "node": ">= 18" }, "funding": { "type": "opencollective", @@ -4892,16 +4915,6 @@ "node": ">= 0.8" } }, - "node_modules/express-openapi-validator/node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/express-openapi-validator/node_modules/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", @@ -4915,6 +4928,151 @@ }, "funding": { "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/express/node_modules/body-parser": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", + "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.0", + "http-errors": "^2.0.0", + "iconv-lite": "^0.6.3", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.0", + "type-is": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/express/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/express/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/express/node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/express/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/express/node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/express/node_modules/raw-body": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz", + "integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.7.0", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/express/node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" } }, "node_modules/faker": { @@ -4990,22 +5148,44 @@ } }, "node_modules/finalhandler": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", - "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", + "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", "license": "MIT", "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "statuses": "2.0.1", - "unpipe": "~1.0.0" + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" }, "engines": { "node": ">= 0.8" } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" }, "node_modules/find-up": { "version": "4.1.0", @@ -5066,12 +5246,12 @@ } }, "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">= 0.8" } }, "node_modules/fs-constants": { @@ -5760,6 +5940,12 @@ "engines": { "node": ">=0.12.0" } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" }, "node_modules/is-stream": { "version": "2.0.1", @@ -6856,10 +7042,13 @@ } }, "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", "license": "MIT", + "engines": { + "node": ">=18" + }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } @@ -6875,6 +7064,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -6892,18 +7082,6 @@ }, "engines": { "node": ">=8.6" - } - }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" } }, "node_modules/mime-db": { @@ -7138,6 +7316,7 @@ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", "license": "MIT", + "optional": true, "engines": { "node": ">= 0.6" } @@ -7634,10 +7813,14 @@ "license": "MIT" }, "node_modules/path-to-regexp": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", - "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", - "license": "MIT" + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } }, "node_modules/picocolors": { "version": "1.1.1", @@ -8083,6 +8266,45 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/router/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -8122,36 +8344,63 @@ } }, "node_modules/send": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", - "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", "license": "MIT", "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "2.4.1", - "range-parser": "~1.2.1", - "statuses": "2.0.1" + "debug": "^4.3.5", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "mime-types": "^3.0.1", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" }, "engines": { - "node": ">= 0.8.0" + "node": ">= 18" } }, - "node_modules/send/node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "node_modules/send/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/send/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "license": "MIT", "engines": { - "node": ">= 0.8" + "node": ">= 0.6" + } + }, + "node_modules/send/node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" } }, "node_modules/send/node_modules/ms": { @@ -8161,18 +8410,18 @@ "license": "MIT" }, "node_modules/serve-static": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", - "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", "license": "MIT", "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "0.19.0" + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" }, "engines": { - "node": ">= 0.8.0" + "node": ">= 18" } }, "node_modules/set-blocking": { @@ -9139,15 +9388,6 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" - }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", diff --git a/server/package.json b/server/package.json --- a/server/package.json +++ b/server/package.json @@ -14,7 +14,7 @@ "bcryptjs": "2.4.3", "body-parser": "^1.20.3", "cors": "^2.8.5", - "express": "4.21.2", + "express": "^5.1.0", "express-openapi-validator": "^5.6.0", "jsonwebtoken": "9.0.2", "sqlite-async": "ndp/sqlite-async#13-typescript", diff --git a/app/app/_layout.tsx b/app/app/_layout.tsx --- a/app/app/_layout.tsx +++ b/app/app/_layout.tsx @@ -24,7 +24,9 @@ - + void | Promise; +} + +export const AccountCard = ({ account, onPress }: AccountCardProps) => { + const chevronColor = useThemeColor({}, "text"); + + const { id, name, balance, iban } = account; + const rate = 50 - Number(iban.slice(-2)); + + const color = useThemeColor( + {}, + Number.isNaN(rate) ? "text" : rate > 0 ? "green" : "red", + ); + + return ( + onPress?.(account)}> + + + {name} + + + + {balance} + + {rate > 0 ? "+" : ""} + {Number.isNaN(rate) ? 0 : rate}% + + + + + + + + ); +}; + +const styles = StyleSheet.create({ + accountContainer: { + display: "flex", + flexDirection: "row", + justifyContent: "space-between", + paddingVertical: 4, + gap: 4, + }, + accountDetail: { + flexDirection: "column", + justifyContent: "center", + paddingVertical: 2, + }, + balanceDetail: { + alignItems: "flex-end", + }, +}); diff --git a/app/components/container-view.tsx b/app/components/container-view.tsx new file mode 100644 --- /dev/null +++ b/app/components/container-view.tsx @@ -0,0 +1,48 @@ +import type { PropsWithChildren, ReactElement } from "react"; +import { StyleSheet, ViewStyle } from "react-native"; + +import { ThemedView } from "@/components/theme"; +import { useThemeColor } from "@/hooks/use-theme-color"; + +const HEADER_HEIGHT = 250; + +type Props = PropsWithChildren<{ + headerImage?: ReactElement; + headerBackgroundColor?: { dark: string; light: string }; + style?: ViewStyle; +}>; + +export default function ContainerView({ + children, + headerImage, + style = {}, +}: Props) { + const backgroundColor = useThemeColor({}, "background"); + + return ( + + {headerImage ? ( + {headerImage} + ) : null} + {children} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + paddingTop: 25, + justifyContent: "space-between", + }, + header: { + height: HEADER_HEIGHT, + overflow: "hidden", + }, + content: { + flex: 1, + padding: 32, + gap: 16, + overflow: "hidden", + }, +}); diff --git a/app/components/parallax-scroll-view.tsx b/app/components/parallax-scroll-view.tsx --- a/app/components/parallax-scroll-view.tsx +++ b/app/components/parallax-scroll-view.tsx @@ -1,15 +1,15 @@ -import type { PropsWithChildren, ReactElement } from 'react'; -import { StyleSheet } from 'react-native'; +import type { PropsWithChildren, ReactElement } from "react"; +import { StyleSheet } from "react-native"; import Animated, { interpolate, useAnimatedRef, useAnimatedStyle, useScrollOffset, -} from 'react-native-reanimated'; +} from "react-native-reanimated"; -import { ThemedView } from '@/components/themed-view'; -import { useColorScheme } from '@/hooks/use-color-scheme'; -import { useThemeColor } from '@/hooks/use-theme-color'; +import { ThemedView } from "@/components/theme"; +import { useColorScheme } from "@/hooks/use-color-scheme"; +import { useThemeColor } from "@/hooks/use-theme-color"; const HEADER_HEIGHT = 250; @@ -23,38 +23,42 @@ headerImage, headerBackgroundColor, }: Props) { - const backgroundColor = useThemeColor({}, 'background'); - const colorScheme = useColorScheme() ?? 'light'; + const backgroundColor = useThemeColor({}, "background"); + const colorScheme = useColorScheme() ?? "light"; const scrollRef = useAnimatedRef(); const scrollOffset = useScrollOffset(scrollRef); - const headerAnimatedStyle = useAnimatedStyle(() => { - return { - transform: [ - { - translateY: interpolate( - scrollOffset.value, - [-HEADER_HEIGHT, 0, HEADER_HEIGHT], - [-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75] - ), - }, - { - scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]), - }, - ], - }; - }); + const headerAnimatedStyle = useAnimatedStyle(() => ({ + transform: [ + { + translateY: interpolate( + scrollOffset.value, + [-HEADER_HEIGHT, 0, HEADER_HEIGHT], + [-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75], + ), + }, + { + scale: interpolate( + scrollOffset.value, + [-HEADER_HEIGHT, 0, HEADER_HEIGHT], + [2, 1, 1], + ), + }, + ], + })); return ( + scrollEventThrottle={16} + > + ]} + > {headerImage} {children} @@ -68,12 +72,12 @@ }, header: { height: HEADER_HEIGHT, - overflow: 'hidden', + overflow: "hidden", }, content: { flex: 1, padding: 32, gap: 16, - overflow: 'hidden', + overflow: "hidden", }, }); diff --git a/app/components/themed-text.tsx b/app/components/themed-text.tsx deleted file mode 100644 --- a/app/components/themed-text.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { StyleSheet, Text, type TextProps } from 'react-native'; - -import { useThemeColor } from '@/hooks/use-theme-color'; - -export type ThemedTextProps = TextProps & { - lightColor?: string; - darkColor?: string; - type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link'; -}; - -export function ThemedText({ - style, - lightColor, - darkColor, - type = 'default', - ...rest -}: ThemedTextProps) { - const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text'); - - return ( - - ); -} - -const styles = StyleSheet.create({ - default: { - fontSize: 16, - lineHeight: 24, - }, - defaultSemiBold: { - fontSize: 16, - lineHeight: 24, - fontWeight: '600', - }, - title: { - fontSize: 32, - fontWeight: 'bold', - lineHeight: 32, - }, - subtitle: { - fontSize: 20, - fontWeight: 'bold', - }, - link: { - lineHeight: 30, - fontSize: 16, - color: '#0a7ea4', - }, -}); diff --git a/app/components/themed-view.tsx b/app/components/themed-view.tsx deleted file mode 100644 --- a/app/components/themed-view.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { View, type ViewProps } from 'react-native'; - -import { useThemeColor } from '@/hooks/use-theme-color'; - -export type ThemedViewProps = ViewProps & { - lightColor?: string; - darkColor?: string; -}; - -export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) { - const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background'); - - return ; -} diff --git a/app/constants/theme.ts b/app/constants/theme.ts --- a/app/constants/theme.ts +++ b/app/constants/theme.ts @@ -1,24 +1,28 @@ import { Platform } from "react-native"; -const tintColorLight = "#0a7ea4"; -const tintColorDark = "#fff"; +const tintColorLight = "#1E66F5"; +const tintColorDark = "#89B4FA"; export const Colors = { light: { - text: "#11181C", - background: "#fff", + text: "#4C4F69", + background: "#EFF1F5", tint: tintColorLight, - icon: "#687076", - tabIconDefault: "#687076", + icon: "#9CA0B0", + tabIconDefault: "#9CA0B0", tabIconSelected: tintColorLight, + red: "#D20F39", + green: "#40A02B", }, dark: { - text: "#ECEDEE", - background: "#151718", + text: "#CDD6F4", + background: "#1E1E2E", tint: tintColorDark, - icon: "#9BA1A6", - tabIconDefault: "#9BA1A6", + icon: "#6C7086", + tabIconDefault: "#6C7086", tabIconSelected: tintColorDark, + red: "#F38BA8", + green: "#A6E3A1", }, }; diff --git a/app/hooks/use-account.ts b/app/hooks/use-account.ts new file mode 100644 --- /dev/null +++ b/app/hooks/use-account.ts @@ -0,0 +1,14 @@ +import { getAccountsByAccountIdOptions } from "@/generated/@tanstack/react-query.gen"; +import { useQuery } from "@tanstack/react-query"; + +export const useAccount = (accountId: number) => { + const { data } = useQuery( + getAccountsByAccountIdOptions({ + path: { accountId }, + }), + ); + + return { + account: data, + }; +}; diff --git a/app/hooks/use-authentication.ts b/app/hooks/use-authentication.ts --- a/app/hooks/use-authentication.ts +++ b/app/hooks/use-authentication.ts @@ -12,7 +12,7 @@ return; } - router.replace("/(auth)"); + router.push("/(auth)"); }, [token, router]), ); }; diff --git a/app/hooks/use-login.ts b/app/hooks/use-login.ts --- a/app/hooks/use-login.ts +++ b/app/hooks/use-login.ts @@ -1,14 +1,50 @@ -import { useRouter } from "expo-router"; +import { useState, useEffect, useCallback } from "react"; import { useMutation } from "@tanstack/react-query"; -import { LoginRequest, postLogin } from "@/generated"; +import { LoginRequest, LoginError, postLogin } from "@/generated"; +import { z } from "zod"; import { useToken } from "@/providers/token-provider"; -import { useEffect } from "react"; +import { useRouter } from "expo-router"; + +const FieldErrorSchema = z.object({ + errors: z.array(z.string()), +}); + +const LoginErrorSchema = z.object({ + errors: z.array(z.string()), + properties: z + .object({ + username: FieldErrorSchema.optional(), + password: FieldErrorSchema.optional(), + }) + .optional(), +}); + +const isLoginError = (e: unknown): e is LoginError => { + const { success } = LoginErrorSchema.safeParse(e); + return success; +}; + +const fromError = (error: unknown): LoginError => ({ + errors: [error instanceof Error ? error.message : "unknown error"], + properties: {}, +}); export const useLogin = () => { - const { token, setToken } = useToken(); + const { setToken, token } = useToken(); const router = useRouter(); - const { mutateAsync } = useMutation({ - mutationFn: (credentials: LoginRequest) => postLogin({ body: credentials }), + const [error, setError] = useState(null); + + const { + isPending, + mutateAsync, + error: mutationError, + data, + } = useMutation({ + mutationFn: (body: LoginRequest) => { + setError(null); + + return postLogin({ body }); + }, }); useEffect(() => { @@ -17,12 +53,36 @@ } }, [token, router]); - return { - login: (credentials: LoginRequest) => - mutateAsync(credentials).then(({ data }) => { - if (data) { - setToken(data); + useEffect(() => { + if (mutationError) { + setError(fromError(mutationError)); + } + }, [mutationError, setError]); + + const login = useCallback( + async (request: LoginRequest) => { + try { + const { data, error } = await mutateAsync(request); + + const realError = data && "error" in data ? data.error : error; + + if (realError || !data) { + throw realError ?? "no data"; } - }), + + setToken(data); + router.replace("/(tabs)"); + } catch (e) { + setError(isLoginError(e) ? e : fromError(e)); + } + }, + [mutateAsync, setToken, router], + ); + + return { + data, + error, + loading: isPending, + login, }; }; diff --git a/app/hooks/use-rate-color.ts b/app/hooks/use-rate-color.ts new file mode 100644 --- /dev/null +++ b/app/hooks/use-rate-color.ts @@ -0,0 +1,7 @@ +import { useThemeColor } from "./use-theme-color"; + +export const useRateColor = (value: number): string => + useThemeColor( + {}, + Number.isNaN(value) || value === 0 ? "text" : value > 0 ? "green" : "red", + ); diff --git a/app/hooks/use-theme-color.ts b/app/hooks/use-theme-color.ts --- a/app/hooks/use-theme-color.ts +++ b/app/hooks/use-theme-color.ts @@ -3,19 +3,15 @@ * https://docs.expo.dev/guides/color-schemes/ */ -import { Colors } from '@/constants/theme'; -import { useColorScheme } from '@/hooks/use-color-scheme'; +import { Colors } from "@/constants/theme"; +import { useColorScheme } from "@/hooks/use-color-scheme"; export function useThemeColor( props: { light?: string; dark?: string }, - colorName: keyof typeof Colors.light & keyof typeof Colors.dark + colorName: keyof typeof Colors.light & keyof typeof Colors.dark, ) { - const theme = useColorScheme() ?? 'light'; + const theme = useColorScheme() ?? "light"; const colorFromProps = props[theme]; - if (colorFromProps) { - return colorFromProps; - } else { - return Colors[theme][colorName]; - } + return colorFromProps || Colors[theme][colorName]; } diff --git a/app/hooks/use-transactions.ts b/app/hooks/use-transactions.ts new file mode 100644 --- /dev/null +++ b/app/hooks/use-transactions.ts @@ -0,0 +1,35 @@ +import { useInfiniteQuery } from "@tanstack/react-query"; +import { getTransactionsInfiniteOptions } from "@/generated/@tanstack/react-query.gen"; +import { GetTransactionsData } from "@/generated"; + +export type TransactionFilterOptions = Omit< + Exclude, + "accountId" | "page" | "limit" +>; + +export const useTransactions = ( + accountId?: number, + filter: TransactionFilterOptions = {}, +) => { + const { + data: transactions, + fetchNextPage, + hasNextPage, + isRefetching, + refetch, + } = useInfiniteQuery({ + ...getTransactionsInfiniteOptions({ + query: { accountId, ...filter }, + }), + getNextPageParam: ({ meta: { hasMore, page } }) => + hasMore ? page + 1 : undefined, + }); + + return { + fetchNextPage, + hasNextPage, + isRefetching, + refetch, + transactions, + }; +}; diff --git a/app/providers/auth-provider.tsx b/app/providers/auth-provider.tsx deleted file mode 100644 --- a/app/providers/auth-provider.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { postLogin } from "@/generated"; -import { useQuery, } from "@tanstack/react-query"; -import { createContext, PropsWithChildren } from "react" - -const AuthContext = createContext(null); - -type AuthProviderProps = PropsWithChildren - -export const AuthProvider = ({ children }: AuthProviderProps) => { - - const { data } = useQuery({ - queryKey: ['auth'], - queryFn: () => postLogin({ body: { username: 'test@test.test', password: 'test@123' } }), - staleTime: 1000 * 60 * 5, // 5 minutes - }) - - console.log(data) - - return {children} -} diff --git a/app/providers/query-client-provider.tsx b/app/providers/query-client-provider.tsx --- a/app/providers/query-client-provider.tsx +++ b/app/providers/query-client-provider.tsx @@ -15,13 +15,12 @@ type MiddlewareParams = { token: TokenPair; - onRefresh: (token: TokenPair | null) => void; }; const createMiddleware = - ({ token, onRefresh }: MiddlewareParams) => + ({ token }: MiddlewareParams) => async (request: Request) => { - const freshToken = await refreshToken({ token, onRefresh }); + const freshToken = await refreshToken({ token }); if (freshToken) { // TODO: it would be nice to have an AbortController here @@ -32,16 +31,14 @@ }; export const QueryClientProvider = ({ children }: PropsWithChildren) => { - const { token, setToken } = useToken(); + const { token } = useToken(); useEffect(() => { if (!token) { return; } - const id = client.interceptors.request.use( - createMiddleware({ token, onRefresh: setToken }), - ); + const id = client.interceptors.request.use(createMiddleware({ token })); return () => client.interceptors.request.eject(id); }); diff --git a/app/providers/token-provider.tsx b/app/providers/token-provider.tsx --- a/app/providers/token-provider.tsx +++ b/app/providers/token-provider.tsx @@ -50,9 +50,11 @@ (async () => { const t = await SecureStore.getItemAsync(tokenSymbol); const state = t ? tokenSchema.parse(JSON.parse(t)) : null; - const fresh = state - ? await refreshToken({ token: state, onRefresh: setToken }) - : null; + const fresh = state ? await refreshToken({ token: state }) : null; + + if (fresh?.accessToken !== state?.accessToken) { + setToken(fresh); + } setTokenState(fresh); setReady(true); diff --git a/app/services/token-service.ts b/app/services/token-service.ts --- a/app/services/token-service.ts +++ b/app/services/token-service.ts @@ -4,12 +4,10 @@ interface TokenServiceParams { token: TokenPair; - onRefresh: (token: MaybeToken) => void | Promise; } export const refreshToken = async ({ token, - onRefresh, }: TokenServiceParams): Promise => { const { expires, refreshToken } = token; @@ -19,7 +17,6 @@ const refreshed = await postRefreshToken({ body: { refreshToken } }); const newToken = refreshed?.data ?? null; - onRefresh(newToken); return newToken; }; diff --git a/app/services/utils.ts b/app/services/utils.ts new file mode 100644 --- /dev/null +++ b/app/services/utils.ts @@ -0,0 +1,18 @@ +import Ionicons from "@expo/vector-icons/Ionicons"; + +export type MaybeArray = T | T[]; + +export const wrap = (value: MaybeArray): T[] => + Array.isArray(value) ? value : [value]; + +type IconName = keyof (typeof Ionicons)["glyphMap"]; + +const mapping: Record = { + deposit: "bonfire-sharp", + invoice: "document", + payment: "compass", + withdrawal: "caret-down", +}; + +export const mapTransactionTypeToIcon = (value: string): IconName => + mapping[value]; diff --git a/server/src/openapi.yaml b/server/src/openapi.yaml --- a/server/src/openapi.yaml +++ b/server/src/openapi.yaml @@ -108,12 +108,40 @@ expiry: { type: string, example: "12/26" } cvv: { type: string, example: "123" } + PaginationMeta: + type: object + required: [page, limit, total, hasMore] + properties: + page: + type: integer + description: Current page number (1-based) + limit: + type: integer + description: Page size + hasMore: + type: boolean + description: True if there are more pages after the current one + total: + type: integer + description: Total number of matching items + + PaginatedTransactions: + type: object + required: [data, meta] + properties: + data: + type: array + items: + $ref: "#/components/schemas/Transaction" + meta: + $ref: "#/components/schemas/PaginationMeta" + TransactionType: type: object required: [name, count] properties: - name: { type: string} - count: { type: integer } + name: { type: string } + count: { type: integer } Transaction: type: object @@ -163,6 +191,42 @@ properties: message: type: string + + LoginError: + type: object + description: Standard validation/error envelope. + required: [errors] + properties: + errors: + type: array + description: Non-field/global errors. + items: + type: string + properties: + type: object + description: Per-field validation errors. + properties: + username: + $ref: "#/components/schemas/FieldError" + password: + $ref: "#/components/schemas/FieldError" + + example: + errors: [] + properties: + username: + errors: ["Invalid email address"] + password: + errors: ["Too small: expected string to have >=8 characters"] + + FieldError: + type: object + required: [errors] + properties: + errors: + type: array + items: + type: string tags: - name: Meta @@ -240,7 +304,7 @@ content: application/json: schema: - $ref: "#/components/schemas/Error" + $ref: "#/components/schemas/LoginError" "500": $ref: "#/components/responses/Error500" @@ -290,6 +354,33 @@ "500": $ref: "#/components/responses/Error500" + /accounts/{accountId}: + get: + summary: Retrieves a single account for the authenticated user, including calculated balances + parameters: + - in: path + name: accountId + schema: + type: integer + required: true + description: Numeric ID of the account to get + tags: [Accounts] + security: + - BearerAuth: [] + responses: + "200": + description: Account object + content: + application/json: + schema: + $ref: "#/components/schemas/Account" + "401": + $ref: "#/components/responses/Error401" + "403": + $ref: "#/components/responses/Error403" + "500": + $ref: "#/components/responses/Error500" + /cards: get: summary: List stored cards for the authenticated user @@ -315,7 +406,7 @@ /transaction-types: get: - summary: Find transaction types for the authenticated user + summary: Find transaction types for the authenticated user tags: [Transactions] security: - BearerAuth: [] @@ -397,9 +488,7 @@ content: application/json: schema: - type: array - items: - $ref: "#/components/schemas/Transaction" + $ref: "#/components/schemas/PaginatedTransactions" "400": $ref: "#/components/responses/Error400" "401": diff --git a/server/src/schema.ts b/server/src/schema.ts --- a/server/src/schema.ts +++ b/server/src/schema.ts @@ -1,10 +1,9 @@ import { Request as ExpressRequest } from "express"; import { z } from "zod"; -export type Request = {}> = - ExpressRequest & { - user?: User; - }; +export type Request = ExpressRequest & { + user?: User; +}; export type User = { id: number; @@ -14,10 +13,33 @@ created: Date; }; +export type Account = { + id: number; + name: string; + user_id: number; + iban: string; + balance: number; +}; + +export type Transaction = { + id: number; + user_id: number; + account_id: number; + amount: number; + type: string; + description: string; + date: string; +}; + export const LoginSchema = z.object({ username: z.email(), password: z.string().min(8), }); + +export type TransactionType = { + name: string; + count: number; +}; export const TransactionTypesQuerySchema = z.object({ accountId: z.preprocess( @@ -26,15 +48,30 @@ ), }); +export type PaginationRespone = { + data: T[]; + meta: { + total: number; + page: number; + hasMore: boolean; + }; +}; + +export type TransactionTypesQuery = z.infer; + export const TransactionsQuerySchema = z.object({ search: z.string().optional().default(""), sort: z.string().optional().default("date"), order: z.enum(["asc", "desc"]).optional().default("desc"), - page: z.preprocess((val) => Number(val ?? 1), z.int().min(1)), - limit: z.preprocess((val) => Number(val ?? 25), z.int().min(1).max(100)), + page: z.preprocess((val) => Number(val ?? 1), z.int().min(1)).default(1), + limit: z + .preprocess((val) => Number(val ?? 25), z.int().min(1).max(100)) + .default(25), accountId: z.preprocess( (val) => (val ? Number(val) : undefined), z.number().optional(), ), type: z.string().optional(), }); + +export type TransactionsQuery = z.infer; diff --git a/server/src/seeder.ts b/server/src/seeder.ts --- a/server/src/seeder.ts +++ b/server/src/seeder.ts @@ -106,20 +106,24 @@ ]) ).map(({ id }) => id); - const transactions = range(100).map(() => [ - userId, - accountIds[Math.floor(Math.random() * accountIds.length)], - faker.finance.amount({ min: -1500, max: 2500, dec: 2 }), - faker.finance.transactionType(), - faker.commerce.productName(), - faker.date.recent({ days: 30 }).toISOString(), - ]); + await Promise.all( + accountIds.map(async (accountId) => { + const transactions = range(1000).map(() => [ + userId, + accountId, + faker.finance.amount({ min: -1500, max: 2500, dec: 2 }), + faker.finance.transactionType(), + faker.commerce.productName(), + faker.date.recent({ days: 30 }).toISOString(), + ]); - // Seed transactions - db.run( - `INSERT INTO transactions (user_id, account_id, amount, type, description, date) VALUES ${transactions - .map(() => "(?, ?, ?, ?, ?, ?)") - .join(", ")}`, - transactions.flat(), + // Seed transactions + db.run( + `INSERT INTO transactions (user_id, account_id, amount, type, description, date) VALUES ${transactions + .map(() => "(?, ?, ?, ?, ?, ?)") + .join(", ")}`, + transactions.flat(), + ); + }), ); }; diff --git a/server/src/server.ts b/server/src/server.ts --- a/server/src/server.ts +++ b/server/src/server.ts @@ -11,11 +11,18 @@ JWT_SECRET, TOKEN_EXPIRY_MINUTES, } from "./config"; -import { LoginRequest, TokenPair, User as UserResponse } from "../generated"; +import { + Account, + LoginRequest, + TokenPair, + User as UserResponse, +} from "../generated"; import { readFileSync } from "fs"; import { generateRefreshToken, generateToken, verifyToken } from "./auth"; import { User, Request, TransactionsQuerySchema, LoginSchema } from "./schema"; import { HttpError } from "express-openapi-validator/dist/framework/types"; +import { AccountService } from "./services/accounts.service"; +import { TransactionService } from "./services/transactions.service"; const authenticateToken = async ( req: Request, @@ -48,6 +55,9 @@ export const build = ({ db, specPath = "./src/openapi.yaml" }: AppConfig) => { const app = express(); + const accountService = new AccountService(db); + const transactionService = new TransactionService(db); + app.use(json()); app.use(cors({ origin: CORS_ORIGIN })); @@ -69,23 +79,6 @@ res.send(readFileSync(specPath).toString()); }); - app.use((err: Error, req: Request, res: Response, next: NextFunction) => { - if (err instanceof ZodError) { - res.status(400).json(z.treeifyError(err)); - return; - } - - if (err instanceof HttpError) { - res.status(err.status).json({ message: err.message }).send(); - return; - } - - res - .status(500) - .json({ error: `Internal server error: ${err.message}` }) - .send(); - }); - app.post("/login", async ({ body }: Request, res: Response) => { const { username, password } = LoginSchema.parse(body); @@ -93,8 +86,8 @@ username, ]); - if (!user || !compare(password, user.password)) { - return res.status(401).json({ message: "Invalid credentials" }); + if (!user || !(await compare(password, user.password))) { + return res.status(401).json({ errors: ["Invalid credentials"] }); } const now = new Date(); @@ -107,7 +100,7 @@ refreshToken: generateRefreshToken(user), }; - res.json(response); + res.json(response).status(200); }); app.post("/refresh-token", async ({ body }: Request, res: Response) => { @@ -153,33 +146,34 @@ created: new Date(user.created).toISOString(), }; - res.json(response); + res?.json(response); }); + + app.get( + "/accounts/:id", + authenticateToken, + async ( + { user, params: { id } }: Request<{ id: number }>, + res: Response, + ) => { + const account: Account = await accountService.getAccountById(user, id); + + if (!account) { + return res + .status(404) + .json({ message: `Account with id ${id} not found` }) + .send(); + } + + res.json(account); + }, + ); app.get( "/accounts", authenticateToken, async ({ user }: Request, res: Response) => { - const accounts = await db.all( - ` - SELECT accounts.*, - IFNULL(t.balance, 0) as balance - - FROM accounts - - LEFT JOIN ( - SELECT account_id, SUM(amount) as balance - FROM transactions - WHERE user_id = ? - GROUP BY account_id - ) AS t ON accounts.id = t.account_id - - WHERE accounts.user_id = ? - `, - [user.id, user.id], - ); - - return res.json(accounts ?? []); + res.json(await accountService.getAccountsForUser(user)); }, ); @@ -198,79 +192,46 @@ app.get( "/transaction-types", authenticateToken, - async (request: Request, res: Response) => { - const { accountId } = TransactionsQuerySchema.parse(request.query); + async ({ query, user }: Request, res: Response) => { + const { accountId } = TransactionsQuerySchema.parse(query); - let query = ` - SELECT count(*) as count, - type as name - FROM transactions - WHERE user_id = ? - `; - - let params: Array = [request.user.id]; - - if (accountId) { - query += " AND (account_id = ?)"; - params.push(accountId); - } - - query += " GROUP BY type ORDER BY count DESC"; - - return res.json((await db.all(query, params)) ?? []); + return res.json( + await transactionService.getTransactionTypes(user.id, accountId), + ); }, ); app.get( "/transactions", authenticateToken, - async (req: Request, res: Response) => { - const { page, limit, search, sort, order, accountId, type } = - TransactionsQuerySchema.parse(req.query); + async ({ query, user: { id: userId } }: Request, res: Response) => { + const queryParams = TransactionsQuerySchema.parse(query); - const offset = (page - 1) * limit; - - let query = "SELECT * FROM transactions WHERE user_id = ?"; - - let params: Array = [req.user.id]; - - if (search.length) { - query += " AND (description LIKE ? OR type LIKE ?)"; - params.push(`%${search}%`, `%${search}%`); - } - - if (accountId) { - query += " AND (account_id = ?)"; - params.push(accountId); - } - - if (type) { - query += " AND (type = ?)"; - params.push(type); - } - - query += ` ORDER BY ? ${order} LIMIT ? OFFSET ?`; - params.push(sort, limit, offset); - - return res.json( - ( - (await db.all<{ - id: number; - user_id: number; - account_id: number; - amount: number; - type: string; - description: string; - date: string; - }>(query, params)) ?? [] - ).map(({ user_id, account_id, ...rest }) => ({ - userId: user_id, - accountId: account_id, - ...rest, - })), + const response = await transactionService.paginatedTransactions( + userId, + queryParams, ); + + res.json(response); }, ); + + app.use((err: Error, req: Request, res: Response, next: NextFunction) => { + if (err instanceof ZodError) { + res.status(400).json(z.treeifyError(err)); + return; + } + + if (err instanceof HttpError) { + res.status(err.status).json({ message: err.message }).send(); + return; + } + + res + .status(500) + .json({ error: `Internal server error: ${err.message}` }) + .send(); + }); return app; }; diff --git a/server/tests/api.spec.ts b/server/tests/api.spec.ts --- a/server/tests/api.spec.ts +++ b/server/tests/api.spec.ts @@ -3,7 +3,7 @@ import { Database } from "sqlite-async"; import { DATABASE_URL } from "../src/config"; import { seed, testUser } from "../src/seeder"; -import { Transaction } from "../generated"; +import { PaginatedTransactions, Transaction } from "../generated"; type App = ReturnType; let app: App | null = null; @@ -28,6 +28,23 @@ it("it fails getting transactions without a token", async () => { await request(app).get("/accounts").expect(401); + }); +}); + +describe("GET /accounts/:id", () => { + it("it can fetch a single accouns", async () => { + const auth = await request(app).post("/login").send(testUser); + + const accounts = await request(app) + .get("/accounts") + .set("Authorization", `Bearer ${auth.body.accessToken}`) + .expect(({ body }) => expect(body).not.toHaveLength(0)); + + await request(app) + .get(`/accounts/${accounts.body[0].id}`) + .set("Authorization", `Bearer ${auth.body.accessToken}`) + .expect(200) + .expect(({ body }) => expect(body.id).toBe(accounts.body[0].id)); }); }); @@ -65,9 +82,9 @@ .get(`/transactions?accountId=${account.body[0].id}`) .set("Authorization", `Bearer ${auth.body.accessToken}`) .expect(200) - .expect(({ body }) => expect(body).not.toHaveLength(0)) - .expect(({ body }: { body: Transaction[] }) => { - body.forEach((transaction) => { + .expect(({ body }) => expect(body.data).not.toHaveLength(0)) + .expect(({ body }: { body: PaginatedTransactions }) => { + body.data.forEach((transaction) => { expect(transaction.accountId).toBe(account.body[0].id); }); }); @@ -86,9 +103,9 @@ .get(`/transactions?type=${transactionType.body[0].name}`) .set("Authorization", `Bearer ${auth.body.accessToken}`) .expect(200) - .expect(({ body }) => expect(body).not.toHaveLength(0)) + .expect(({ body }) => expect(body.data).not.toHaveLength(0)) .expect(({ body }) => { - body.forEach(({ type }: Transaction) => { + body.data.forEach(({ type }: Transaction) => { expect(type).toBe(transactionType.body[0].name); }); }); diff --git a/app/app/(auth)/index.tsx b/app/app/(auth)/index.tsx --- a/app/app/(auth)/index.tsx +++ b/app/app/(auth)/index.tsx @@ -1,32 +1,95 @@ +import { useState } from "react"; import { StyleSheet } from "react-native"; +import { useRouter } from "expo-router"; -import { ThemedText } from "@/components/themed-text"; -import { ThemedView } from "@/components/themed-view"; +import ContainerView from "@/components/container-view"; +import { LoginRequest } from "@/generated"; +import { + ThemedText, + ThemedTextInput, + ThemedView, + ThemedButton, + ThemedErrorBox, +} from "@/components/theme"; import { useLogin } from "@/hooks/use-login"; -import { Button } from "@react-navigation/elements"; +import { useThemeColor } from "@/hooks/use-theme-color"; -export default function HomeScreen() { - const { login } = useLogin(); +export default function LoginScreen() { + const { login, loading, error } = useLogin(); + const router = useRouter(); + const borderColor = useThemeColor({}, "text"); + + const [formData, setFormData] = useState({ + username: "", + password: "", + }); return ( - - Welcome! - - + + + Welcome! + + setFormData((state) => ({ ...state, username })) + } + /> + + setFormData((state) => ({ ...state, password })) + } + /> + + + login(formData)}> + {loading ? "loading.." : "login"} + + router.push("./settings")} + > + settings + + + + ); } const styles = StyleSheet.create({ + page: { + flex: 1, + justifyContent: "center", + alignItems: "center", + flexDirection: "row", + }, titleContainer: { paddingTop: 60, flexDirection: "row", alignItems: "center", gap: 8, + }, + formContainer: { + borderRadius: 8, + borderWidth: 1, + padding: 16, + display: "flex", + }, + buttonGroup: { + display: "flex", + flexDirection: "row", + justifyContent: "space-around", }, }); diff --git a/app/app/(auth)/settings.tsx b/app/app/(auth)/settings.tsx --- a/app/app/(auth)/settings.tsx +++ b/app/app/(auth)/settings.tsx @@ -1,20 +1,14 @@ -import { StyleSheet } from "react-native"; +import ContainerView from "@/components/container-view"; +import { ThemedText, ThemedView } from "@/components/theme"; +import { ThemeToggle } from "@/components/shared/theme-toggle"; -import { ThemedText } from "@/components/themed-text"; -import { ThemedView } from "@/components/themed-view"; - -export default function SettingsScreen() { +export default function LoginSettingsScreen() { return ( - - Settings - + + + Settings + + + ); } - -const styles = StyleSheet.create({ - titleContainer: { - flexDirection: "row", - alignItems: "center", - gap: 8, - }, -}); diff --git a/app/app/(tabs)/_layout.tsx b/app/app/(tabs)/_layout.tsx --- a/app/app/(tabs)/_layout.tsx +++ b/app/app/(tabs)/_layout.tsx @@ -1,11 +1,11 @@ -import { Tabs } from 'expo-router'; -import React from 'react'; +import { Tabs } from "expo-router"; +import React from "react"; -import { HapticTab } from '@/components/haptic-tab'; -import { IconSymbol } from '@/components/ui/icon-symbol'; -import { Colors } from '@/constants/theme'; -import { useColorScheme } from '@/hooks/use-color-scheme'; -import { useAuthentication } from '@/hooks/use-authentication'; +import { HapticTab } from "@/components/haptic-tab"; +import { IconSymbol } from "@/components/ui/icon-symbol"; +import { Colors } from "@/constants/theme"; +import { useColorScheme } from "@/hooks/use-color-scheme"; +import { useAuthentication } from "@/hooks/use-authentication"; export default function TabLayout() { const colorScheme = useColorScheme(); @@ -15,29 +15,43 @@ return ( + }} + > , + title: "Home", + tabBarIcon: ({ color }) => ( + + ), }} /> , + title: "Cards", + tabBarIcon: ({ color }) => ( + + ), }} /> , + title: "Settings", + tabBarIcon: ({ color }) => ( + + ), + }} + /> + diff --git a/app/app/(tabs)/account.tsx b/app/app/(tabs)/account.tsx --- a/app/app/(tabs)/account.tsx +++ b/app/app/(tabs)/account.tsx @@ -1,13 +1,12 @@ import { Image } from "expo-image"; import { StyleSheet } from "react-native"; import ParallaxScrollView from "@/components/parallax-scroll-view"; -import { ThemedText } from "@/components/themed-text"; -import { ThemedView } from "@/components/themed-view"; -import { Button } from "@react-navigation/elements"; +import { ThemedText, ThemedView, ThemedButton } from "@/components/theme"; import { useCurrentUser } from "@/hooks/use-current-user"; import { useLogout } from "@/hooks/use-logout"; +import { ThemeToggle } from "@/components/shared/theme-toggle"; -export default function ProfileScreen() { +export default function SettingsScreen() { const { logout } = useLogout(); const { user } = useCurrentUser(); @@ -23,14 +22,13 @@ } > - {`Welcome ${user?.fullname}`} + Settings - - {`username: ${user?.username}`} + + logged in as {user?.username} + logout()}>logout - - - + ); } @@ -38,6 +36,12 @@ const styles = StyleSheet.create({ titleContainer: { flexDirection: "row", + alignItems: "center", + gap: 8, + }, + settingsContainer: { + flexDirection: "row", + justifyContent: "space-between", alignItems: "center", gap: 8, }, diff --git a/app/app/(tabs)/cards.tsx b/app/app/(tabs)/cards.tsx new file mode 100644 --- /dev/null +++ b/app/app/(tabs)/cards.tsx @@ -0,0 +1,75 @@ +import { Image } from "expo-image"; +import { StyleSheet } from "react-native"; + +import ParallaxScrollView from "@/components/parallax-scroll-view"; +import { ThemedText, ThemedView } from "@/components/theme"; +import { useQuery } from "@tanstack/react-query"; +import { getCardsOptions } from "@/generated/@tanstack/react-query.gen"; +import { useCurrentUser } from "@/hooks/use-current-user"; +import { AccountNumber } from "@/components/ui/accountnumber"; + +export default function HomeScreen() { + const { data: cardData } = useQuery(getCardsOptions()); + + const { user } = useCurrentUser(); + + return ( + + } + > + + Welcome back, {user?.fullname}! + + + {cardData?.map(({ id, number, cvv, expiry }) => ( + + + {number} + + + CVV: {cvv} + Expiry: {expiry} + + + ))} + + + ); +} + +const styles = StyleSheet.create({ + titleContainer: { + flexDirection: "row", + alignItems: "center", + gap: 8, + }, + stepContainer: { + gap: 8, + marginBottom: 8, + }, + reactLogo: { + height: 178, + width: 290, + bottom: 0, + left: 0, + position: "absolute", + }, + accountContainer: { + display: "flex", + flexDirection: "row", + justifyContent: "space-between", + paddingVertical: 4, + gap: 4, + }, + accountDetail: { + flexDirection: "column", + justifyContent: "center", + paddingVertical: 2, + }, +}); diff --git a/app/app/(tabs)/explore.tsx b/app/app/(tabs)/explore.tsx deleted file mode 100644 --- a/app/app/(tabs)/explore.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { Image } from 'expo-image'; -import { Platform, StyleSheet } from 'react-native'; - -import { Collapsible } from '@/components/ui/collapsible'; -import { ExternalLink } from '@/components/external-link'; -import ParallaxScrollView from '@/components/parallax-scroll-view'; -import { ThemedText } from '@/components/themed-text'; -import { ThemedView } from '@/components/themed-view'; -import { IconSymbol } from '@/components/ui/icon-symbol'; -import { Fonts } from '@/constants/theme'; - -export default function TabTwoScreen() { - return ( - - }> - - - Explore - - - This app includes example code to help you get started. - - - This app has two screens:{' '} - app/(tabs)/index.tsx and{' '} - app/(tabs)/explore.tsx - - - The layout file in app/(tabs)/_layout.tsx{' '} - sets up the tab navigator. - - - Learn more - - - - - You can open this project on Android, iOS, and the web. To open the web version, press{' '} - w in the terminal running this project. - - - - - For static images, you can use the @2x and{' '} - @3x suffixes to provide files for - different screen densities - - - - Learn more - - - - - This template has light and dark mode support. The{' '} - useColorScheme() hook lets you inspect - what the user's current color scheme is, and so you can adjust UI colors accordingly. - - - Learn more - - - - - This template includes an example of an animated component. The{' '} - components/HelloWave.tsx component uses - the powerful{' '} - - react-native-reanimated - {' '} - library to create a waving hand animation. - - {Platform.select({ - ios: ( - - The components/ParallaxScrollView.tsx{' '} - component provides a parallax effect for the header image. - - ), - })} - - - ); -} - -const styles = StyleSheet.create({ - headerImage: { - color: '#808080', - bottom: -90, - left: -35, - position: 'absolute', - }, - titleContainer: { - flexDirection: 'row', - gap: 8, - }, -}); diff --git a/app/app/(tabs)/index.tsx b/app/app/(tabs)/index.tsx --- a/app/app/(tabs)/index.tsx +++ b/app/app/(tabs)/index.tsx @@ -1,20 +1,19 @@ import { Image } from "expo-image"; -import { StyleSheet, TouchableOpacity } from "react-native"; +import { StyleSheet } from "react-native"; +import { useRouter } from "expo-router"; +import { useQuery } from "@tanstack/react-query"; import ParallaxScrollView from "@/components/parallax-scroll-view"; -import { ThemedText } from "@/components/themed-text"; -import { ThemedView } from "@/components/themed-view"; -import { useQuery } from "@tanstack/react-query"; +import { ThemedText, ThemedView } from "@/components/theme"; import { getAccountsOptions } from "@/generated/@tanstack/react-query.gen"; import { useCurrentUser } from "@/hooks/use-current-user"; -import { IconSymbol } from "@/components/ui/icon-symbol"; -import { Price } from "@/components/ui/price"; -import { AccountNumber } from "@/components/ui/accountnumber"; +import { AccountCard } from "@/components/account-card"; export default function HomeScreen() { const { data: accountData } = useQuery(getAccountsOptions()); const { user } = useCurrentUser(); + const router = useRouter(); return ( - Welcome back, {user?.fullname}! + Bank Accounts + + + welcome back, {user?.fullname} - {accountData?.map(({ id, name, balance, iban }) => ( - - - - {name} - - - - {balance} - +20% - - - - - - + {accountData?.map((account) => ( + router.push(`./accounts/${account.id}`)} + /> ))} @@ -68,17 +60,5 @@ bottom: 0, left: 0, position: "absolute", - }, - accountContainer: { - display: "flex", - flexDirection: "row", - justifyContent: "space-between", - paddingVertical: 4, - gap: 4, - }, - accountDetail: { - flexDirection: "column", - justifyContent: "center", - paddingVertical: 2, }, }); diff --git a/app/components/shared/theme-toggle.tsx b/app/components/shared/theme-toggle.tsx new file mode 100644 --- /dev/null +++ b/app/components/shared/theme-toggle.tsx @@ -0,0 +1,28 @@ +import { StyleSheet, Appearance, useColorScheme } from "react-native"; +import { ThemedText, ThemedView, ThemedButton } from "@/components/theme"; + +export const ThemeToggle = () => { + const colorScheme = useColorScheme(); + + return ( + + colorscheme set to {colorScheme} + + Appearance.setColorScheme(colorScheme === "dark" ? "light" : "dark") + } + > + toggle + + + ); +}; + +const styles = StyleSheet.create({ + settingsContainer: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + gap: 8, + }, +}); diff --git a/app/components/theme/index.tsx b/app/components/theme/index.tsx new file mode 100644 --- /dev/null +++ b/app/components/theme/index.tsx @@ -0,0 +1,6 @@ +export * from "./themed-button"; +export * from "./themed-error"; +export * from "./themed-error-box"; +export * from "./themed-text"; +export * from "./themed-text-input"; +export * from "./themed-view"; diff --git a/app/components/theme/themed-button.tsx b/app/components/theme/themed-button.tsx new file mode 100644 --- /dev/null +++ b/app/components/theme/themed-button.tsx @@ -0,0 +1,44 @@ +import { StyleSheet, TouchableOpacity } from "react-native"; +import { ThemedText, ThemedView } from "@/components/theme"; +import { useThemeColor } from "@/hooks/use-theme-color"; + +interface ThemedButtonProps { + ariaText?: string; + children: string; + disabled?: boolean; + onPress: () => void | Promise; +} + +export const ThemedButton = ({ + ariaText, + children, + disabled, + onPress, +}: ThemedButtonProps) => { + const backgroundColor = useThemeColor({}, "background"); + const color = useThemeColor({}, "text"); + + return ( + (!disabled ? await onPress() : null)} + > + + {children} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 8, + display: "flex", + borderWidth: 1, + }, + text: {}, +}); diff --git a/app/components/theme/themed-error-box.tsx b/app/components/theme/themed-error-box.tsx new file mode 100644 --- /dev/null +++ b/app/components/theme/themed-error-box.tsx @@ -0,0 +1,26 @@ +import { StyleSheet } from "react-native"; +import { ThemedView } from "./themed-view"; +import { wrap, MaybeArray } from "@/services/utils"; +import { ThemedError } from "./themed-error"; + +interface ThemedErrorBoxProps { + errors?: MaybeArray; +} + +export const ThemedErrorBox = ({ errors }: ThemedErrorBoxProps) => { + return errors && wrap(errors).length > 0 ? ( + + {wrap(errors)?.map((error, index) => ( + + {error instanceof Error ? error.message : error} + + ))} + + ) : null; +}; + +const styles = StyleSheet.create({ + container: { + padding: 8, + }, +}); diff --git a/app/components/theme/themed-error.tsx b/app/components/theme/themed-error.tsx new file mode 100644 --- /dev/null +++ b/app/components/theme/themed-error.tsx @@ -0,0 +1,27 @@ +import { StyleSheet } from "react-native"; +import { ThemedText } from "./themed-text"; +import { useThemeColor } from "@/hooks/use-theme-color"; + +interface ThemedErrorProps { + children?: string | null; +} + +export const ThemedError = ({ children }: ThemedErrorProps) => { + const textColor = useThemeColor({}, "red"); + + return children ? ( + + {children} + + ) : null; +}; + +const styles = StyleSheet.create({ + container: {}, + text: {}, +}); diff --git a/app/components/theme/themed-text-input.tsx b/app/components/theme/themed-text-input.tsx new file mode 100644 --- /dev/null +++ b/app/components/theme/themed-text-input.tsx @@ -0,0 +1,62 @@ +import { StyleSheet, TextInput } from "react-native"; +import { ThemedView } from "./themed-view"; +import { ThemedErrorBox } from "./themed-error-box"; +import { useThemeColor } from "@/hooks/use-theme-color"; + +interface ThemedTextInputProps { + // see https://reactnative.dev/docs/textinput#autocomplete + autoComplete?: "current-password" | "email"; + // see https://reactnative.dev/docs/textinput#enterKeyHint + nextHint?: "next" | "done" | "search" | "go"; + onChange: (value: string) => void | Promise; + placeholder: string; + value?: string; + disabled?: boolean; + type?: "text" | "password"; + error?: string | string[]; +} + +export const ThemedTextInput = ({ + disabled, + nextHint, + onChange, + placeholder, + type, + value, + error, +}: ThemedTextInputProps) => { + const borderColor = useThemeColor({}, error?.length ? "red" : "text"); + + const textColor = useThemeColor({}, "text"); + + return ( + + + + + ); +}; + +const styles = StyleSheet.create({ + input: { + borderRadius: 8, + padding: 8, + borderWidth: 1, + }, + inputContainer: { + borderRadius: 8, + padding: 4, + marginVertical: 4, + }, +}); diff --git a/app/components/theme/themed-text.tsx b/app/components/theme/themed-text.tsx new file mode 100644 --- /dev/null +++ b/app/components/theme/themed-text.tsx @@ -0,0 +1,69 @@ +import { StyleSheet, Text, type TextProps } from 'react-native'; + +import { useThemeColor } from '@/hooks/use-theme-color'; + +export type ThemeColor = 'text' | 'red' | 'green' + +export type ThemedTextProps = TextProps & { + color?: ThemeColor; + lightColor?: string; + darkColor?: string; + type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link' | 'small'; +}; + +export function ThemedText({ + style, + color: textColor = 'text', + lightColor, + darkColor, + type = 'default', + ...rest +}: ThemedTextProps) { + const color = useThemeColor({ light: lightColor, dark: darkColor }, textColor); + + return ( + + ); +} + +const styles = StyleSheet.create({ + default: { + fontSize: 16, + lineHeight: 24, + }, + small: { + fontSize: 12, + lineHeight: 18, + }, + defaultSemiBold: { + fontSize: 16, + lineHeight: 24, + fontWeight: '600', + }, + title: { + fontSize: 32, + fontWeight: 'bold', + lineHeight: 32, + }, + subtitle: { + fontSize: 20, + fontWeight: 'bold', + }, + link: { + lineHeight: 30, + fontSize: 16, + color: '#0a7ea4', + }, +}); diff --git a/app/components/theme/themed-view.tsx b/app/components/theme/themed-view.tsx new file mode 100644 --- /dev/null +++ b/app/components/theme/themed-view.tsx @@ -0,0 +1,14 @@ +import { View, type ViewProps } from 'react-native'; + +import { useThemeColor } from '@/hooks/use-theme-color'; + +export type ThemedViewProps = ViewProps & { + lightColor?: string; + darkColor?: string; +}; + +export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) { + const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background'); + + return ; +} diff --git a/app/components/transaction/transaction-card.tsx b/app/components/transaction/transaction-card.tsx new file mode 100644 --- /dev/null +++ b/app/components/transaction/transaction-card.tsx @@ -0,0 +1,63 @@ +import { StyleSheet, TouchableOpacity } from "react-native"; + +import { ThemedView, ThemedText } from "@/components/theme"; +import { Price } from "@/components/ui/price"; +import { Transaction } from "@/generated"; +import Ionicons from "@expo/vector-icons/Ionicons"; +import { mapTransactionTypeToIcon } from "@/services/utils"; + +interface TransactionCardProps { + transaction: Transaction; + onPress?: (transaction: Transaction) => Promise | void; +} + +export const TransactionCard = ({ + onPress, + transaction, +}: TransactionCardProps) => { + const { description, amount, date, type } = transaction; + + return ( + onPress?.(transaction)}> + + + + + + {description} + {type} + + + {amount} + + {new Date(date).toLocaleDateString()} + + + + + ); +}; + +const styles = StyleSheet.create({ + transactionContainer: { + display: "flex", + flexDirection: "row", + justifyContent: "space-between", + paddingVertical: 4, + gap: 4, + }, + transactionDetail: { + flexDirection: "column", + justifyContent: "center", + paddingVertical: 2, + padding: 4, + }, + descriptionDetail: { + flexGrow: 1, + }, + priceDetail: { + alignItems: "flex-end", + }, +}); diff --git a/app/components/transaction/transaction-filter.tsx b/app/components/transaction/transaction-filter.tsx new file mode 100644 --- /dev/null +++ b/app/components/transaction/transaction-filter.tsx @@ -0,0 +1,141 @@ +import { useState } from "react"; +import { StyleSheet, TouchableOpacity } from "react-native"; +import { useQuery } from "@tanstack/react-query"; + +import { + ThemedText, + ThemedButton, + ThemedView, + ThemedTextInput, +} from "@/components/theme"; +import { TransactionFilterOptions } from "@/hooks/use-transactions"; +import { useThemeColor } from "@/hooks/use-theme-color"; +import { useAccount } from "@/hooks/use-account"; +import { getTransactionTypesOptions } from "@/generated/@tanstack/react-query.gen"; +import Ionicons from "@expo/vector-icons/Ionicons"; +import { mapTransactionTypeToIcon } from "@/services/utils"; + +interface TransactionFilterProps { + accountId?: number; + initialState?: TransactionFilterOptions; + onCancel?: () => void | Promise; + onSubmit?: (filter: TransactionFilterOptions) => void | Promise; +} + +const sortDirections = ["asc", "desc"] as const; + +export const TransactionFilter = ({ + accountId, + initialState = {}, + onCancel, + onSubmit, +}: TransactionFilterProps) => { + const color = useThemeColor({}, "text"); + const [filter, setFilter] = useState(initialState); + const { account } = useAccount(accountId ?? 0); + const { data: transactionTypes } = useQuery(getTransactionTypesOptions()); + + return ( + + + filter transactions for + {account?.name} + + + Search by description + + setFilter((current: TransactionFilterOptions) => ({ + ...current, + search, + })) + } + /> + + + Filter by transaction type + {transactionTypes?.map(({ name, count }) => ( + setFilter((current) => ({ ...current, type: name }))} + > + + + + + + + {name} ({count}) + + + + + ))} + + + Sort by + {["amount", "date"]?.map((sort) => ( + setFilter((current) => ({ ...current, sort }))} + > + + + {sort} + + + + ))} + + + Sort direction + {sortDirections?.map((order) => ( + setFilter((current) => ({ ...current, order }))} + > + + + {order} + + + + ))} + + + + onSubmit?.(filter)}>filter + onSubmit?.({})}>clear + onCancel?.()}>cancel + + + ); +}; + +const styles = StyleSheet.create({ + buttonGroup: { + display: "flex", + flexDirection: "row", + justifyContent: "space-around", + }, + filterContainer: { + borderRadius: 8, + borderWidth: 1, + padding: 8, + }, +}); diff --git a/app/components/transaction/transaction-list.tsx b/app/components/transaction/transaction-list.tsx new file mode 100644 --- /dev/null +++ b/app/components/transaction/transaction-list.tsx @@ -0,0 +1,55 @@ +import { FlashList } from "@shopify/flash-list"; +import { RefreshControl, TouchableOpacity } from "react-native"; +import { ThemedText } from "@/components/theme"; + +import { TransactionCard } from "./transaction-card"; + +import { + useTransactions, + TransactionFilterOptions, +} from "@/hooks/use-transactions"; + +interface TransactionListProps { + accountId?: number; + filter?: TransactionFilterOptions; +} + +export const TransactionList = ({ + accountId, + filter, +}: TransactionListProps) => { + const { transactions, isRefetching, refetch, hasNextPage, fetchNextPage } = + useTransactions(accountId, filter); + + return ( + data)} + keyExtractor={({ id }) => id.toString()} + refreshControl={ + + } + ListFooterComponent={ + hasNextPage ? ( + fetchNextPage} + style={{ display: "flex" }} + > + fetch more + + ) : null + } + renderItem={({ item }) => ( + + )} + onEndReachedThreshold={0.95} + onEndReached={() => { + fetchNextPage(); + }} + /> + ); +}; diff --git a/app/components/ui/accountnumber.tsx b/app/components/ui/accountnumber.tsx --- a/app/components/ui/accountnumber.tsx +++ b/app/components/ui/accountnumber.tsx @@ -1,24 +1,25 @@ import { TouchableOpacity } from "react-native"; -import { ThemedText } from "../themed-text"; +import { ThemedText, ThemedView } from "@/components/theme"; import { useToggle } from "@/hooks/use-toggle"; import { blurAccountNumber } from "@/services/blur-accountnumber"; import { PropsWithChildren } from "react"; -import { ThemedView } from "../themed-view"; interface AccountNumberProps { children: string; hidden?: boolean; + size?: "default" | "small"; } export const AccountNumber = ({ children, hidden = false, + size = "default", }: AccountNumberProps) => { const { isHidden, toggleHidden } = useToggle(true); return ( - + {isHidden ? blurAccountNumber(children.toString()) : children} diff --git a/app/components/ui/collapsible.tsx b/app/components/ui/collapsible.tsx --- a/app/components/ui/collapsible.tsx +++ b/app/components/ui/collapsible.tsx @@ -1,28 +1,31 @@ -import { PropsWithChildren, useState } from 'react'; -import { StyleSheet, TouchableOpacity } from 'react-native'; +import { PropsWithChildren, useState } from "react"; +import { StyleSheet, TouchableOpacity } from "react-native"; -import { ThemedText } from '@/components/themed-text'; -import { ThemedView } from '@/components/themed-view'; -import { IconSymbol } from '@/components/ui/icon-symbol'; -import { Colors } from '@/constants/theme'; -import { useColorScheme } from '@/hooks/use-color-scheme'; +import { ThemedText, ThemedView } from "@/components/theme"; +import { IconSymbol } from "@/components/ui/icon-symbol"; +import { Colors } from "@/constants/theme"; +import { useColorScheme } from "@/hooks/use-color-scheme"; -export function Collapsible({ children, title }: PropsWithChildren & { title: string }) { +export function Collapsible({ + children, + title, +}: PropsWithChildren & { title: string }) { const [isOpen, setIsOpen] = useState(false); - const theme = useColorScheme() ?? 'light'; + const theme = useColorScheme() ?? "light"; return ( setIsOpen((value) => !value)} - activeOpacity={0.8}> + activeOpacity={0.8} + > {title} @@ -34,8 +37,8 @@ const styles = StyleSheet.create({ heading: { - flexDirection: 'row', - alignItems: 'center', + flexDirection: "row", + alignItems: "center", gap: 6, }, content: { diff --git a/app/components/ui/icon-symbol.tsx b/app/components/ui/icon-symbol.tsx --- a/app/components/ui/icon-symbol.tsx +++ b/app/components/ui/icon-symbol.tsx @@ -18,6 +18,8 @@ 'paperplane.fill': 'send', 'chevron.left.forwardslash.chevron.right': 'code', 'chevron.right': 'chevron-right', + 'shield.fill': 'code' + } as IconMapping; /** diff --git a/app/components/ui/price.tsx b/app/components/ui/price.tsx --- a/app/components/ui/price.tsx +++ b/app/components/ui/price.tsx @@ -1,4 +1,4 @@ -import { ThemedText } from "../themed-text"; +import { ThemedText } from "@/components/theme"; interface PriceProps { children: number; @@ -7,7 +7,8 @@ export const Price = ({ children, currency = "€" }: PriceProps) => ( + {children < 0 ? "- " : ""} {currency} - {children.toFixed(2)} + {children.toFixed(2).replaceAll("-", "")} ); diff --git a/server/src/services/accounts.service.ts b/server/src/services/accounts.service.ts new file mode 100644 --- /dev/null +++ b/server/src/services/accounts.service.ts @@ -0,0 +1,36 @@ +import { Database } from "sqlite-async"; +import { Account, User } from "../schema"; + +export class AccountService { + constructor(private db: Database) {} + + getAccountsForUser({ id }: User): Promise { + return this.db.all(this.getBaseQuery(), [id, id]); + } + + getAccountById({ id }: User, accountId: number): Promise { + return this.db.get(this.getBaseQuery() + "AND id = ?", [ + id, + id, + accountId, + ]); + } + + private getBaseQuery(): string { + return ` + SELECT accounts.*, + IFNULL(t.balance, 0) as balance + + FROM accounts + + LEFT JOIN ( + SELECT account_id, SUM(amount) as balance + FROM transactions + WHERE user_id = ? + GROUP BY account_id + ) AS t ON accounts.id = t.account_id + + WHERE accounts.user_id = ? + `; + } +} diff --git a/server/src/services/transactions.service.ts b/server/src/services/transactions.service.ts new file mode 100644 --- /dev/null +++ b/server/src/services/transactions.service.ts @@ -0,0 +1,116 @@ +import { Database } from "sqlite-async"; +import { Transaction, TransactionsQuery, TransactionType } from "../schema"; +import { PaginatedTransactions } from "../../generated"; + +export class TransactionService { + constructor(private db: Database) {} + + getTransactions( + userId: number, + filters: TransactionsQuery, + ): Promise { + const [query, params] = this.buildTransactionsQuery(userId, filters); + + const { page, order, limit, sort } = filters; + const offset = (page - 1) * limit; + + params.push(limit, offset); + + const finalQuery = query.concat( + ` ORDER BY ${sort} ${order} LIMIT ? OFFSET ?`, + ); + + return this.db.all(finalQuery, params); + } + + async countTransactions( + userId: number, + filters: TransactionsQuery, + ): Promise { + const [query, params] = this.buildTransactionsQuery(userId, filters); + + const { count } = await this.db.get<{ count: number }>( + query.replace("SELECT *", "SELECT COUNT(*) as count"), + params, + ); + + return count; + } + + async paginatedTransactions( + userId: number, + filters: TransactionsQuery, + ): Promise { + const [transactions, total] = await Promise.all([ + this.getTransactions(userId, filters), + this.countTransactions(userId, filters), + ]); + + const { page, limit } = filters; + + return { + data: transactions.map(({ user_id, account_id, ...rest }) => ({ + userId: user_id, + accountId: account_id, + ...rest, + })), + meta: { + total, + page, + limit, + hasMore: total > (page - 1) * limit + transactions.length, + }, + }; + } + + private buildTransactionsQuery( + userId: number, + filters: TransactionsQuery, + ): [string, Array] { + const { search, type, accountId } = filters; + + let query = "SELECT * FROM transactions WHERE user_id = ?"; + + let params: Array = [userId]; + + if (search.length) { + query += " AND (description LIKE ? OR type LIKE ?)"; + params.push(`%${search}%`, `%${search}%`); + } + + if (accountId) { + query += " AND (account_id = ?)"; + params.push(accountId); + } + + if (type) { + query += " AND (type = ?)"; + params.push(type); + } + + return [query, params]; + } + + getTransactionTypes( + userId: number, + accountId?: number, + ): Promise { + let query = ` + SELECT count(*) as count, + type as name + FROM transactions + WHERE user_id = ? + `; + + let params: Array = [userId]; + + if (accountId) { + query += " AND (account_id = ?)"; + params.push(accountId); + } + + query += " GROUP BY type ORDER BY count DESC"; + + return this.db.all(query, params); + } +} diff --git a/app/app/(tabs)/accounts/[accountId]/index.tsx b/app/app/(tabs)/accounts/[accountId]/index.tsx new file mode 100644 --- /dev/null +++ b/app/app/(tabs)/accounts/[accountId]/index.tsx @@ -0,0 +1,75 @@ +import { useState } from "react"; +import { StyleSheet, TouchableOpacity, Modal } from "react-native"; +import Ionicons from "@expo/vector-icons/Ionicons"; +import { useLocalSearchParams } from "expo-router"; + +import { ThemedText, ThemedView } from "@/components/theme"; +import { Price } from "@/components/ui/price"; +import { TransactionList } from "@/components/transaction/transaction-list"; +import ContainerView from "@/components/container-view"; +import { useToggle } from "@/hooks/use-toggle"; +import { useThemeColor } from "@/hooks/use-theme-color"; +import { TransactionFilter } from "@/components/transaction/transaction-filter"; +import { useAccount } from "@/hooks/use-account"; +import { TransactionFilterOptions } from "@/hooks/use-transactions"; + +export default function AccountScreen() { + const { isHidden, toggleHidden, setHidden } = useToggle(true); + const [filter, setFilter] = useState({}); + const color = useThemeColor({}, isHidden ? "text" : "green"); + const { accountId: raw } = useLocalSearchParams<{ accountId: string }>(); + const accountId = Number.parseInt(raw); + + const { account } = useAccount(accountId); + + return ( + + setHidden(false)} + > + + setHidden(true)} + onSubmit={(value) => { + setFilter(value); + setHidden(true); + }} + /> + + + + + {account?.name} + {account?.iban} + {account?.balance ?? 0} + + + + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + titleContainer: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + }, + modalContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center", + backgroundColor: "none", + }, +}); -- tangled.sh