diff --git a/.gitignore b/.gitignore index a2123506..f8e55ff6 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,8 @@ .svn/ .swiftpm/ migrate_working_dir/ +node_modules/ +coverage_deno # IntelliJ related *.iml diff --git a/Dockerfile b/Dockerfile index 5abe270f..6de3e322 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,30 +1,52 @@ -# Base image with Flutter SDK and Dart preinstalled +# Base image with Flutter SDK and Dart FROM ghcr.io/cirruslabs/flutter:3.35.3 +# Install dependencies +RUN apt-get update && apt-get install -y \ + curl \ + unzip \ + git \ + ca-certificates \ + gnupg \ + && rm -rf /var/lib/apt/lists/* + +# Install Node.js (includes npm) +RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ + && apt-get install -y nodejs + +# Install Supabase CLI +RUN npm install -g supabase + +# Install Deno +RUN curl -fsSL https://deno.land/install.sh | sh + +# Add Deno to PATH +ENV DENO_INSTALL="/root/.deno" +ENV PATH="$DENO_INSTALL/bin:$PATH" # Set working directory WORKDIR /app -# Copy pubspec first to cache dependencies +# Copy dependency files first (for caching) COPY pubspec.* ./ -# Get Flutter dependencies +# Install Flutter dependencies RUN flutter pub get -# Copy rest of the source code +# Copy rest of project COPY . . -# Create .env file placeholder (mounted at runtime) -RUN touch .env - -# Enable web support just in case +# Enable web support RUN flutter config --enable-web -# Run Flutter tests to verify setup (optional) +# Optional: run tests RUN flutter test -# Expose web dev port +# Expose dev server port EXPOSE 8080 -# Default command for development (serves on web) -CMD ["flutter", "run", "-d", "web-server", "--web-port=8080", "--web-hostname=0.0.0.0"] +# Create .env placeholder (mounted at runtime) +RUN touch .env + +# Default command +CMD ["flutter", "run", "-d", "web-server", "--web-port=8080", "--web-hostname=0.0.0.0"] \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index a817646c..a5c10710 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ xmlns:tools="http://schemas.android.com/tools"> + diff --git a/assets/locationPin.svg b/assets/locationPin.svg new file mode 100644 index 00000000..911c34c4 --- /dev/null +++ b/assets/locationPin.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/deno.json b/deno.json new file mode 100644 index 00000000..e1bf87f5 --- /dev/null +++ b/deno.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "lib": ["deno.ns", "dom", "esnext"] + }, + "test": { + "include": ["supabase/functions/**/*.test.ts"], + "exclude": ["node_modules"] + }, + "nodeModulesDir": "auto" + } \ No newline at end of file diff --git a/deno.lock b/deno.lock new file mode 100644 index 00000000..89be6f73 --- /dev/null +++ b/deno.lock @@ -0,0 +1,366 @@ +{ + "version": "5", + "specifiers": { + "npm:@types/node@^25.3.0": "25.3.0", + "npm:stripe@*": "14.25.0", + "npm:stripe@14": "14.25.0", + "npm:supabase@^2.76.14": "2.76.14" + }, + "npm": { + "@isaacs/fs-minipass@4.0.1": { + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dependencies": [ + "minipass" + ] + }, + "@types/node@25.3.0": { + "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", + "dependencies": [ + "undici-types" + ] + }, + "agent-base@7.1.4": { + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==" + }, + "bin-links@6.0.0": { + "integrity": "sha512-X4CiKlcV2GjnCMwnKAfbVWpHa++65th9TuzAEYtZoATiOE2DQKhSp4CJlyLoTqdhBKlXjpXjCTYPNNFS33Fi6w==", + "dependencies": [ + "cmd-shim", + "npm-normalize-package-bin", + "proc-log", + "read-cmd-shim", + "write-file-atomic" + ] + }, + "call-bind-apply-helpers@1.0.2": { + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": [ + "es-errors", + "function-bind" + ] + }, + "call-bound@1.0.4": { + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dependencies": [ + "call-bind-apply-helpers", + "get-intrinsic" + ] + }, + "chownr@3.0.0": { + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==" + }, + "cmd-shim@8.0.0": { + "integrity": "sha512-Jk/BK6NCapZ58BKUxlSI+ouKRbjH1NLZCgJkYoab+vEHUY3f6OzpNBN9u7HFSv9J6TRDGs4PLOHezoKGaFRSCA==" + }, + "data-uri-to-buffer@4.0.1": { + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==" + }, + "debug@4.4.3": { + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": [ + "ms" + ] + }, + "dunder-proto@1.0.1": { + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": [ + "call-bind-apply-helpers", + "es-errors", + "gopd" + ] + }, + "es-define-property@1.0.1": { + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==" + }, + "es-errors@1.3.0": { + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==" + }, + "es-object-atoms@1.1.1": { + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dependencies": [ + "es-errors" + ] + }, + "fetch-blob@3.2.0": { + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "dependencies": [ + "node-domexception", + "web-streams-polyfill" + ] + }, + "formdata-polyfill@4.0.10": { + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "dependencies": [ + "fetch-blob" + ] + }, + "function-bind@1.1.2": { + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" + }, + "get-intrinsic@1.3.0": { + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": [ + "call-bind-apply-helpers", + "es-define-property", + "es-errors", + "es-object-atoms", + "function-bind", + "get-proto", + "gopd", + "has-symbols", + "hasown", + "math-intrinsics" + ] + }, + "get-proto@1.0.1": { + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": [ + "dunder-proto", + "es-object-atoms" + ] + }, + "gopd@1.2.0": { + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==" + }, + "has-symbols@1.1.0": { + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==" + }, + "hasown@2.0.2": { + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": [ + "function-bind" + ] + }, + "https-proxy-agent@7.0.6": { + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dependencies": [ + "agent-base", + "debug" + ] + }, + "imurmurhash@0.1.4": { + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==" + }, + "math-intrinsics@1.1.0": { + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==" + }, + "minipass@7.1.3": { + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==" + }, + "minizlib@3.1.0": { + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dependencies": [ + "minipass" + ] + }, + "ms@2.1.3": { + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node-domexception@1.0.0": { + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": true + }, + "node-fetch@3.3.2": { + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dependencies": [ + "data-uri-to-buffer", + "fetch-blob", + "formdata-polyfill" + ] + }, + "npm-normalize-package-bin@5.0.0": { + "integrity": "sha512-CJi3OS4JLsNMmr2u07OJlhcrPxCeOeP/4xq67aWNai6TNWWbTrlNDgl8NcFKVlcBKp18GPj+EzbNIgrBfZhsag==" + }, + "object-inspect@1.13.4": { + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==" + }, + "proc-log@6.1.0": { + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==" + }, + "qs@6.15.0": { + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "dependencies": [ + "side-channel" + ] + }, + "read-cmd-shim@6.0.0": { + "integrity": "sha512-1zM5HuOfagXCBWMN83fuFI/x+T/UhZ7k+KIzhrHXcQoeX5+7gmaDYjELQHmmzIodumBHeByBJT4QYS7ufAgs7A==" + }, + "side-channel-list@1.0.0": { + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dependencies": [ + "es-errors", + "object-inspect" + ] + }, + "side-channel-map@1.0.1": { + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dependencies": [ + "call-bound", + "es-errors", + "get-intrinsic", + "object-inspect" + ] + }, + "side-channel-weakmap@1.0.2": { + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dependencies": [ + "call-bound", + "es-errors", + "get-intrinsic", + "object-inspect", + "side-channel-map" + ] + }, + "side-channel@1.1.0": { + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dependencies": [ + "es-errors", + "object-inspect", + "side-channel-list", + "side-channel-map", + "side-channel-weakmap" + ] + }, + "signal-exit@4.1.0": { + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==" + }, + "stripe@14.25.0": { + "integrity": "sha512-wQS3GNMofCXwH8TSje8E1SE8zr6ODiGtHQgPtO95p9Mb4FhKC9jvXR2NUTpZ9ZINlckJcFidCmaTFV4P6vsb9g==", + "dependencies": [ + "@types/node", + "qs" + ] + }, + "supabase@2.76.14": { + "integrity": "sha512-2XmYs8+A4WXd+w/OND9u9qbSTnGdLCuddnii01H1LkmgwcZ9krXwxElE+YYmzhsEKCUHv5wVjAf5HTUwQ4PnVA==", + "dependencies": [ + "bin-links", + "https-proxy-agent", + "node-fetch", + "tar" + ], + "scripts": true, + "bin": true + }, + "tar@7.5.9": { + "integrity": "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==", + "dependencies": [ + "@isaacs/fs-minipass", + "chownr", + "minipass", + "minizlib", + "yallist" + ] + }, + "undici-types@7.18.2": { + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==" + }, + "web-streams-polyfill@3.3.3": { + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==" + }, + "write-file-atomic@7.0.0": { + "integrity": "sha512-YnlPC6JqnZl6aO4uRc+dx5PHguiR9S6WeoLtpxNT9wIG+BDya7ZNE1q7KOjVgaA73hKhKLpVPgJ5QA9THQ5BRg==", + "dependencies": [ + "imurmurhash", + "signal-exit" + ] + }, + "yallist@5.0.0": { + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==" + } + }, + "redirects": { + "https://deno.land/std/testing/asserts.ts": "https://deno.land/std@0.224.0/testing/asserts.ts", + "https://esm.sh/@supabase/functions-js@^2.1.0?target=denonext": "https://esm.sh/@supabase/functions-js@2.97.0?target=denonext", + "https://esm.sh/@supabase/gotrue-js@^2.18.1?target=denonext": "https://esm.sh/@supabase/gotrue-js@2.97.0?target=denonext", + "https://esm.sh/@supabase/node-fetch@^2.6.14?target=denonext": "https://esm.sh/@supabase/node-fetch@2.6.15?target=denonext", + "https://esm.sh/@supabase/postgrest-js@^1.1.1?target=denonext": "https://esm.sh/@supabase/postgrest-js@1.21.4?target=denonext", + "https://esm.sh/@supabase/realtime-js@^2.7.1?target=denonext": "https://esm.sh/@supabase/realtime-js@2.97.0?target=denonext", + "https://esm.sh/@supabase/storage-js@^2.3.1?target=denonext": "https://esm.sh/@supabase/storage-js@2.97.0?target=denonext", + "https://esm.sh/@supabase/supabase-js@2": "https://esm.sh/@supabase/supabase-js@2.97.0", + "https://esm.sh/iceberg-js@^0.8.1?target=denonext": "https://esm.sh/iceberg-js@0.8.1?target=denonext", + "https://esm.sh/tr46@~0.0.3?target=denonext": "https://esm.sh/tr46@0.0.3?target=denonext", + "https://esm.sh/webidl-conversions@^3.0.0?target=denonext": "https://esm.sh/webidl-conversions@3.0.1?target=denonext", + "https://esm.sh/whatwg-url@^5.0.0?target=denonext": "https://esm.sh/whatwg-url@5.0.0?target=denonext" + }, + "remote": { + "https://deno.land/std@0.168.0/fmt/colors.ts": "03ad95e543d2808bc43c17a3dd29d25b43d0f16287fe562a0be89bf632454a12", + "https://deno.land/std@0.168.0/testing/_diff.ts": "a23e7fc2b4d8daa3e158fa06856bedf5334ce2a2831e8bf9e509717f455adb2c", + "https://deno.land/std@0.168.0/testing/_format.ts": "cd11136e1797791045e639e9f0f4640d5b4166148796cad37e6ef75f7d7f3832", + "https://deno.land/std@0.168.0/testing/asserts.ts": "51353e79437361d4b02d8e32f3fc83b22231bc8f8d4c841d86fd32b0b0afe940", + "https://deno.land/std@0.182.0/fmt/colors.ts": "d67e3cd9f472535241a8e410d33423980bec45047e343577554d3356e1f0ef4e", + "https://deno.land/std@0.182.0/testing/_diff.ts": "1a3c044aedf77647d6cac86b798c6417603361b66b54c53331b312caeb447aea", + "https://deno.land/std@0.182.0/testing/_format.ts": "a69126e8a469009adf4cf2a50af889aca364c349797e63174884a52ff75cf4c7", + "https://deno.land/std@0.182.0/testing/asserts.ts": "e16d98b4d73ffc4ed498d717307a12500ae4f2cbe668f1a215632d19fcffc22f", + "https://deno.land/std@0.224.0/assert/_constants.ts": "a271e8ef5a573f1df8e822a6eb9d09df064ad66a4390f21b3e31f820a38e0975", + "https://deno.land/std@0.224.0/assert/assert.ts": "09d30564c09de846855b7b071e62b5974b001bb72a4b797958fe0660e7849834", + "https://deno.land/std@0.224.0/assert/assert_almost_equals.ts": "9e416114322012c9a21fa68e187637ce2d7df25bcbdbfd957cd639e65d3cf293", + "https://deno.land/std@0.224.0/assert/assert_array_includes.ts": "14c5094471bc8e4a7895fc6aa5a184300d8a1879606574cb1cd715ef36a4a3c7", + "https://deno.land/std@0.224.0/assert/assert_equals.ts": "3bbca947d85b9d374a108687b1a8ba3785a7850436b5a8930d81f34a32cb8c74", + "https://deno.land/std@0.224.0/assert/assert_exists.ts": "43420cf7f956748ae6ed1230646567b3593cb7a36c5a5327269279c870c5ddfd", + "https://deno.land/std@0.224.0/assert/assert_false.ts": "3e9be8e33275db00d952e9acb0cd29481a44fa0a4af6d37239ff58d79e8edeff", + "https://deno.land/std@0.224.0/assert/assert_greater.ts": "5e57b201fd51b64ced36c828e3dfd773412c1a6120c1a5a99066c9b261974e46", + "https://deno.land/std@0.224.0/assert/assert_greater_or_equal.ts": "9870030f997a08361b6f63400273c2fb1856f5db86c0c3852aab2a002e425c5b", + "https://deno.land/std@0.224.0/assert/assert_instance_of.ts": "e22343c1fdcacfaea8f37784ad782683ec1cf599ae9b1b618954e9c22f376f2c", + "https://deno.land/std@0.224.0/assert/assert_is_error.ts": "f856b3bc978a7aa6a601f3fec6603491ab6255118afa6baa84b04426dd3cc491", + "https://deno.land/std@0.224.0/assert/assert_less.ts": "60b61e13a1982865a72726a5fa86c24fad7eb27c3c08b13883fb68882b307f68", + "https://deno.land/std@0.224.0/assert/assert_less_or_equal.ts": "d2c84e17faba4afe085e6c9123a63395accf4f9e00150db899c46e67420e0ec3", + "https://deno.land/std@0.224.0/assert/assert_match.ts": "ace1710dd3b2811c391946954234b5da910c5665aed817943d086d4d4871a8b7", + "https://deno.land/std@0.224.0/assert/assert_not_equals.ts": "78d45dd46133d76ce624b2c6c09392f6110f0df9b73f911d20208a68dee2ef29", + "https://deno.land/std@0.224.0/assert/assert_not_instance_of.ts": "3434a669b4d20cdcc5359779301a0588f941ffdc2ad68803c31eabdb4890cf7a", + "https://deno.land/std@0.224.0/assert/assert_not_match.ts": "df30417240aa2d35b1ea44df7e541991348a063d9ee823430e0b58079a72242a", + "https://deno.land/std@0.224.0/assert/assert_not_strict_equals.ts": "37f73880bd672709373d6dc2c5f148691119bed161f3020fff3548a0496f71b8", + "https://deno.land/std@0.224.0/assert/assert_object_match.ts": "411450fd194fdaabc0089ae68f916b545a49d7b7e6d0026e84a54c9e7eed2693", + "https://deno.land/std@0.224.0/assert/assert_rejects.ts": "4bee1d6d565a5b623146a14668da8f9eb1f026a4f338bbf92b37e43e0aa53c31", + "https://deno.land/std@0.224.0/assert/assert_strict_equals.ts": "b4f45f0fd2e54d9029171876bd0b42dd9ed0efd8f853ab92a3f50127acfa54f5", + "https://deno.land/std@0.224.0/assert/assert_string_includes.ts": "496b9ecad84deab72c8718735373feb6cdaa071eb91a98206f6f3cb4285e71b8", + "https://deno.land/std@0.224.0/assert/assert_throws.ts": "c6508b2879d465898dab2798009299867e67c570d7d34c90a2d235e4553906eb", + "https://deno.land/std@0.224.0/assert/assertion_error.ts": "ba8752bd27ebc51f723702fac2f54d3e94447598f54264a6653d6413738a8917", + "https://deno.land/std@0.224.0/assert/equal.ts": "bddf07bb5fc718e10bb72d5dc2c36c1ce5a8bdd3b647069b6319e07af181ac47", + "https://deno.land/std@0.224.0/assert/fail.ts": "0eba674ffb47dff083f02ced76d5130460bff1a9a68c6514ebe0cdea4abadb68", + "https://deno.land/std@0.224.0/assert/mod.ts": "48b8cb8a619ea0b7958ad7ee9376500fe902284bb36f0e32c598c3dc34cbd6f3", + "https://deno.land/std@0.224.0/assert/unimplemented.ts": "8c55a5793e9147b4f1ef68cd66496b7d5ba7a9e7ca30c6da070c1a58da723d73", + "https://deno.land/std@0.224.0/assert/unreachable.ts": "5ae3dbf63ef988615b93eb08d395dda771c96546565f9e521ed86f6510c29e19", + "https://deno.land/std@0.224.0/fmt/colors.ts": "508563c0659dd7198ba4bbf87e97f654af3c34eb56ba790260f252ad8012e1c5", + "https://deno.land/std@0.224.0/internal/diff.ts": "6234a4b493ebe65dc67a18a0eb97ef683626a1166a1906232ce186ae9f65f4e6", + "https://deno.land/std@0.224.0/internal/format.ts": "0a98ee226fd3d43450245b1844b47003419d34d210fa989900861c79820d21c2", + "https://deno.land/std@0.224.0/internal/mod.ts": "534125398c8e7426183e12dc255bb635d94e06d0f93c60a297723abe69d3b22e", + "https://deno.land/std@0.224.0/testing/asserts.ts": "d0cdbabadc49cc4247a50732ee0df1403fdcd0f95360294ad448ae8c240f3f5c", + "https://esm.sh/@supabase/auth-js@2.97.0/denonext/auth-js.mjs": "1647921d7d6c64ddb79cab6feefc5b8f373a97c6421695ae5af69514e42eacf3", + "https://esm.sh/@supabase/functions-js@2.97.0/denonext/functions-js.mjs": "89de9a4b9ff7b3d000c84989e586a53b3905f3e0bf3570b7595e24694b6d4e9e", + "https://esm.sh/@supabase/functions-js@2.97.0?target=denonext": "32cf95c9eb181b1c450e0bfb6e1cdc96bac8c770b02510a3c01be6687d7e764f", + "https://esm.sh/@supabase/gotrue-js@2.97.0/denonext/gotrue-js.mjs": "1e92f463966f625a3dc3b8936399480db099a3514c6df4ef861f23ed25d7ae7a", + "https://esm.sh/@supabase/gotrue-js@2.97.0?target=denonext": "a375a791f3e023b8a0ebea041c16c48bb8fd0e3ada1248ae9494f3b636ee4819", + "https://esm.sh/@supabase/node-fetch@2.6.15/denonext/node-fetch.mjs": "0bae9052231f4f6dbccc7234d05ea96923dbf967be12f402764580b6bf9f713d", + "https://esm.sh/@supabase/node-fetch@2.6.15?target=denonext": "4d28c4ad97328403184353f68434f2b6973971507919e9150297413664919cf3", + "https://esm.sh/@supabase/postgrest-js@1.21.4/denonext/postgrest-js.mjs": "c3769b11ef02debc78ecf6ab4e152d3cf7dbd05bbbafeb72c160e76cc57cda3c", + "https://esm.sh/@supabase/postgrest-js@1.21.4?target=denonext": "db2315bc0ff19690cd4239c5adb5f5787f5dea04955058ef1fca541d49555ed6", + "https://esm.sh/@supabase/postgrest-js@2.97.0/denonext/postgrest-js.mjs": "7cdf9ffe7047f77421dad5af748d5e0e43b6253debdd85f3d71a9dec7bea39b6", + "https://esm.sh/@supabase/realtime-js@2.97.0/denonext/realtime-js.mjs": "4633785191d028eb751065b5f65443e982f14e68840f8c6559cdd300fa37bc4b", + "https://esm.sh/@supabase/realtime-js@2.97.0?target=denonext": "b52f86d9d6b74c8c914ae9a77135db4d0b68f76081eb4414bfeb1815a78941d6", + "https://esm.sh/@supabase/storage-js@2.97.0/denonext/storage-js.mjs": "aed9af8fb795068fb6fb945f76bfe40a4dce0a84e22b60f333ee029b62c91f22", + "https://esm.sh/@supabase/storage-js@2.97.0?target=denonext": "40761f5969795d9628f8a16712c50446018f6fb438512313d5be87b0c1874eb6", + "https://esm.sh/@supabase/supabase-js@2.14.0": "c277fa7166609cff04bb767940520ccbc912e801e24094e7175fcf83c8060d20", + "https://esm.sh/@supabase/supabase-js@2.14.0/denonext/supabase-js.mjs": "a12dd486a259c7b6f1054ff7fd19ec77024ad0168de22c7ce81ad5349d02fac0", + "https://esm.sh/@supabase/supabase-js@2.97.0": "dcdf60d835a7a2edf75c186e8bb33fa5f43fec7dec96c93c89abfbb20b78245d", + "https://esm.sh/@supabase/supabase-js@2.97.0/denonext/supabase-js.mjs": "745f4cc1905e434b7a54fa2d48d875c920f1ef5d8ad9a964f8b079e3c7bd25ca", + "https://esm.sh/iceberg-js@0.8.1/denonext/iceberg-js.mjs": "d839d81a2e3966500ca2cdd0c1cb458e9608bacfc91f7bb67a69b2e878dcdb4f", + "https://esm.sh/iceberg-js@0.8.1?target=denonext": "58c849d7fe2bf4eca4a84eb501e83c161a7d8c34ca1bec15a962b3bec3062633", + "https://esm.sh/tr46@0.0.3/denonext/tr46.mjs": "5753ec0a99414f4055f0c1f97691100f13d88e48a8443b00aebb90a512785fa2", + "https://esm.sh/tr46@0.0.3?target=denonext": "19cb9be0f0d418a0c3abb81f2df31f080e9540a04e43b0f699bce1149cba0cbb", + "https://esm.sh/tslib@2.8.1/denonext/tslib.mjs": "c38da5dd6da6281964435002ce204cd586634fe3bdfb24a0ee116f48cf3292e9", + "https://esm.sh/webidl-conversions@3.0.1/denonext/webidl-conversions.mjs": "238cd0743827707cbc1c2d7c2cf1027dbc536fb53ec9a3fde0ff8026a3ac5385", + "https://esm.sh/webidl-conversions@3.0.1?target=denonext": "4e20318d50528084616c79d7b3f6e7f0fe7b6d09013bd01b3974d7448d767e29", + "https://esm.sh/whatwg-url@5.0.0/denonext/whatwg-url.mjs": "29b16d74ee72624c915745bbd25b617cfd2248c6af0f5120d131e232a9a9af79", + "https://esm.sh/whatwg-url@5.0.0?target=denonext": "f001a2cadf81312d214ca330033f474e74d81a003e21e8c5d70a1f46dc97b02d" + }, + "workspace": { + "packageJson": { + "dependencies": [ + "npm:@types/node@^25.3.0", + "npm:supabase@^2.76.14" + ] + } + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 77478ee0..d14c099c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,5 +11,14 @@ services: - .env command: flutter run -d web-server --web-port=8080 --web-hostname=0.0.0.0 + dev: + build: . + volumes: + - .:/app + working_dir: /app + command: bash + stdin_open: true + tty: true + volumes: flutter_cache: diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist index 1dc6cf76..d69d74da 100644 --- a/ios/Flutter/AppFrameworkInfo.plist +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -22,5 +22,9 @@ 1.0 MinimumOSVersion 13.0 + UIBackgroundModes + + location + diff --git a/ios/Podfile.lock b/ios/Podfile.lock index f834c51c..baee355c 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -6,9 +6,14 @@ PODS: - Flutter - flutter_native_splash (2.4.3): - Flutter + - geolocator_apple (1.2.0): + - Flutter + - FlutterMacOS - mobile_scanner (7.0.0): - Flutter - FlutterMacOS + - package_info_plus (0.4.5): + - Flutter - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS @@ -80,7 +85,9 @@ DEPENDENCIES: - Flutter (from `Flutter`) - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) + - geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`) - mobile_scanner (from `.symlinks/plugins/mobile_scanner/darwin`) + - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) @@ -107,8 +114,12 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/flutter_local_notifications/ios" flutter_native_splash: :path: ".symlinks/plugins/flutter_native_splash/ios" + geolocator_apple: + :path: ".symlinks/plugins/geolocator_apple/darwin" mobile_scanner: :path: ".symlinks/plugins/mobile_scanner/darwin" + package_info_plus: + :path: ".symlinks/plugins/package_info_plus/ios" path_provider_foundation: :path: ".symlinks/plugins/path_provider_foundation/darwin" permission_handler_apple: @@ -125,7 +136,9 @@ SPEC CHECKSUMS: Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100 flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf + geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93 + package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 3b03487e..5f3d730c 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -5,13 +5,9 @@ CADisableMinimumFrameDurationOnPhone NSCameraUsageDescription - Scan your card to add it automatically - NSCameraUsageDescription - To scan cards + Camera permission is required for scanning QR codes and cards CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) - NSCameraUsageDescription - Camera permission is required for scanning QR codes CFBundleDisplayName Clean Stream Laundry App CFBundleExecutable @@ -64,5 +60,9 @@ UIStatusBarHidden + NSLocationWhenInUseUsageDescription + Your location is required to find the nearest Clean Stream Laundry location + NSLocationAlwaysAndWhenInUseUsageDescription + Your location is used to find nearby Clean Stream Laundry locations and improve location-based services in the app. - + \ No newline at end of file diff --git a/lib/logic/parsing/location_parser.dart b/lib/logic/parsing/location_parser.dart index b3bff27c..90a4a2fd 100644 --- a/lib/logic/parsing/location_parser.dart +++ b/lib/logic/parsing/location_parser.dart @@ -1,8 +1,13 @@ import 'package:clean_stream_laundry_app/widgets/map_marker.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; +import 'package:geolocator/geolocator.dart'; class LocationParser { + final GeolocatorPlatform? geolocator; + + LocationParser({this.geolocator}); + static List parseLocations(List> locations) { List markers = []; @@ -24,4 +29,66 @@ class LocationParser { } return markers; } -} + + Future determinePosition() async { + LocationPermission permission; + + // Use injected geolocator if provided (for tests), otherwise use Geolocator + permission = geolocator != null + ? await geolocator!.checkPermission() + : await Geolocator.checkPermission(); + + if (permission == LocationPermission.denied) { + permission = geolocator != null + ? await geolocator!.requestPermission() + : await Geolocator.requestPermission(); + if (permission == LocationPermission.denied) { + return Future.error('Location Permissions are denied'); + } + } + + Position position = geolocator != null + ? await geolocator!.getCurrentPosition() + : await Geolocator.getCurrentPosition(); + return position.toString(); + } + Future> parseCurrentLocation() async { + final positionString = await determinePosition(); + final parts = positionString.split(', '); + final List coords = []; + + coords.add(double.parse(parts[0].split(': ')[1])); + coords.add(double.parse(parts[1].split(': ')[1])); + return coords; + } + + Future?> getNearestLocation( + List> locations, + ) async { + final coords = await parseCurrentLocation(); + final userLat = coords[0]; + final userLng = coords[1]; + + Map? nearest; + double shortestDistance = double.infinity; + + for (var location in locations) { + if (location.containsKey('Latitude') && + location.containsKey('Longitude')) { + final distance = Geolocator.distanceBetween( + userLat, + userLng, + location['Latitude'].toDouble(), + location['Longitude'].toDouble(), + ); + + if (distance < shortestDistance) { + shortestDistance = distance; + nearest = location; + } + } + } + + return nearest; + } +} \ No newline at end of file diff --git a/lib/logic/parsing/transaction_parser.dart b/lib/logic/parsing/transaction_parser.dart index a565b993..04d3a4a5 100644 --- a/lib/logic/parsing/transaction_parser.dart +++ b/lib/logic/parsing/transaction_parser.dart @@ -20,7 +20,7 @@ class TransactionParser { return ""; } - final action = description == "Loyalty Card" ? "added to" : "used on"; + final action = description == "Loyalty Card" ? "added to" : "-"; return '$formattedAmount $action $description on $formattedDate'; } @@ -68,6 +68,7 @@ class TransactionParser { 'directDryer': 0.0, 'loyaltyDryer': 0.0, 'loyaltyCard': 0.0, + 'Rewards': 0.0 }; } @@ -100,6 +101,9 @@ class TransactionParser { } else if (description == 'loyalty card') { result[monthKey]!['loyaltyCard'] = result[monthKey]!['loyaltyCard']! + amount; + } else if (description == 'reward from payment') { + result[monthKey]!['Rewards'] = + result[monthKey]!['Rewards']! + amount; } } return result; diff --git a/lib/logic/payment/process_payment.dart b/lib/logic/payment/process_payment.dart index cf0016ac..7a1634d9 100644 --- a/lib/logic/payment/process_payment.dart +++ b/lib/logic/payment/process_payment.dart @@ -2,16 +2,12 @@ import 'package:clean_stream_laundry_app/logic/services/payment_service.dart'; import 'package:clean_stream_laundry_app/logic/services/transaction_service.dart'; import 'package:flutter_stripe/flutter_stripe.dart'; import 'package:clean_stream_laundry_app/logic/enums/payment_result_enum.dart'; +import 'package:get_it/get_it.dart'; class PaymentProcessor { - final PaymentService _paymentService; - final TransactionService _transactionService; + final PaymentService _paymentService = GetIt.instance(); + final TransactionService _transactionService = GetIt.instance(); - PaymentProcessor({ - required PaymentService paymentService, - required TransactionService transactionService, - }) : _paymentService = paymentService, - _transactionService = transactionService; Future processPayment( double amount, @@ -24,6 +20,7 @@ class PaymentProcessor { description: description, type: "Laundry", ); + return PaymentResult.success; } on StripeException { return PaymentResult.canceled; diff --git a/lib/logic/services/auth_service.dart b/lib/logic/services/auth_service.dart index 120cae41..99146e30 100644 --- a/lib/logic/services/auth_service.dart +++ b/lib/logic/services/auth_service.dart @@ -18,7 +18,7 @@ abstract class AuthService { Future resetPassword(String email); Future appleSignIn(); Future googleSignIn(); - Future handleOAuthRedirect(Uri uri); + Future getSessionFromURI(Uri uri); Future refreshSession(); User? getCurrentUser(); String? getCurrentUserEmail(); @@ -28,4 +28,5 @@ abstract class AuthService { }); Future exchangeCodeForSession(String code); Future updatePassword(String newPassword); + Future verifyCode({required String email, required String code}); } diff --git a/lib/logic/services/door_unlock_service.dart b/lib/logic/services/door_unlock_service.dart new file mode 100644 index 00000000..0908c1a9 --- /dev/null +++ b/lib/logic/services/door_unlock_service.dart @@ -0,0 +1,4 @@ +abstract class DoorUnlockService { + Future> getNearbyDoors(); + Future unlockDoor(String doorId); +} \ No newline at end of file diff --git a/lib/logic/services/profile_service.dart b/lib/logic/services/profile_service.dart index 33b2603b..05284c6c 100644 --- a/lib/logic/services/profile_service.dart +++ b/lib/logic/services/profile_service.dart @@ -1,10 +1,11 @@ abstract class ProfileService { Future createAccount({required String id, required String name}); Future?> getUserBalanceById(String userId); - Future updateBalanceById(double balance); + Future updateBalanceById(String userId, double balance); Future getUserNameById(String userId); Future getUserRefundAttempts(String userId); Future updateName(String name); Future getNotificationLeadTime(); Future setNotificationLeadTime(int value); + Future updateRewardsById(String userId, double amount); } diff --git a/lib/logic/services/transaction_service.dart b/lib/logic/services/transaction_service.dart index c6caf5dc..57d5aa37 100644 --- a/lib/logic/services/transaction_service.dart +++ b/lib/logic/services/transaction_service.dart @@ -2,7 +2,7 @@ import 'dart:async'; abstract class TransactionService { Future recordTransaction({required double amount, required String description, required String type,}); Future>> getTransactionsForUser(); - Future>> getRefundableTransactionsForUser(); + Future<({List transactions, List ids})> getRefundableTransactionsForUser(); Future recordRefundRequest({required String transaction_id, required String description,}); Future subscribeForPaymentConfirmation( bool channelSubscribed,Completer? paymentCompleter ); } \ No newline at end of file diff --git a/lib/logic/theme/theme.dart b/lib/logic/theme/theme.dart index 4c636427..2af82548 100644 --- a/lib/logic/theme/theme.dart +++ b/lib/logic/theme/theme.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; ThemeData lightMode = ThemeData( + useMaterial3: true, brightness: Brightness.light, colorScheme: ColorScheme.light( brightness: Brightness.light, @@ -8,60 +9,85 @@ ThemeData lightMode = ThemeData( primary: Color(0xFF2073A9), secondary: Color(0xFFf3c404), tertiary: Colors.indigo[900], - ) + ), + appBarTheme: const AppBarTheme( + backgroundColor: Colors.transparent, + elevation: 0, + surfaceTintColor: Colors.transparent, + ), ); ThemeData darkMode = ThemeData( + useMaterial3: true, brightness: Brightness.dark, - colorScheme: ColorScheme.light( - brightness: Brightness.dark, - surface: Colors.grey.shade900, - primary: Color(0xFF2073A9), - secondary: Color(0xFFf3c404), - tertiary: Colors.deepPurple, - ) + colorScheme: ColorScheme.dark( + brightness: Brightness.dark, + surface: Colors.grey.shade900, + primary: Color(0xFF2073A9), + secondary: Color(0xFFf3c404), + tertiary: Colors.deepPurple, + ), + appBarTheme: const AppBarTheme( + backgroundColor: Colors.transparent, + elevation: 0, + surfaceTintColor: Colors.transparent, + ), ); extension ModeChangerText on ColorScheme { String get modeChangerText { - return brightness == Brightness.dark - ? "Light Mode" - : "Dark Mode"; + return brightness == Brightness.dark ? "Light Mode" : "Dark Mode"; } Color get fontPrimary { - return brightness == Brightness.dark - ? Colors.black - : Colors.white; + return brightness == Brightness.dark ? Colors.black : Colors.white; } Color get fontInverted { - return brightness == Brightness.dark - ? Colors.white - : Colors.black; + return brightness == Brightness.dark ? Colors.white : Colors.black; } Color get fontSecondary { - return brightness == Brightness.dark - ? Colors.grey - : Colors.black87; + return brightness == Brightness.dark ? Colors.grey : Colors.black87; } Color get cardPrimary { - return brightness == Brightness.dark - ? Color(0xFFCFCFCD) - : Colors.white; + return brightness == Brightness.dark ? Color(0xFFCFCFCD) : Colors.white; } Color get cardSecondary { - return brightness == Brightness.dark - ? Color(0xFF2073A9) - : Colors.white; + return brightness == Brightness.dark ? Color(0xFF2073A9) : Colors.white; } - Color get greyCard{ + Color get greyCard { return brightness == Brightness.dark ? Color(0xFFCFCFCD) : Color(0xEECFCFCD); } } + +extension GradientScheme on ColorScheme { + LinearGradient get primaryGradient { + return brightness == Brightness.dark + ? LinearGradient( + colors: [Color(0xFF2073A9), Colors.deepPurple], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ) + : LinearGradient( + colors: [Color(0xFF2073A9), Color(0xFF13BDFA)], + begin: Alignment.centerLeft, + end: Alignment.centerRight, + ); + } + + LinearGradient get backgroundGradient { + return brightness == Brightness.dark + ? LinearGradient( + colors: [Color.fromARGB(255, 248, 248, 232), Color(0xFFE1E1E1)], + ) + : LinearGradient( + colors: [Color.fromARGB(255, 245, 237, 226), Colors.white], + ); + } +} diff --git a/lib/logic/viewmodels/loyalty_view_model.dart b/lib/logic/viewmodels/loyalty_view_model.dart index 1f1dc97c..a0f3c143 100644 --- a/lib/logic/viewmodels/loyalty_view_model.dart +++ b/lib/logic/viewmodels/loyalty_view_model.dart @@ -7,6 +7,7 @@ import '../parsing/transaction_parser.dart'; import '../enums/payment_result_enum.dart'; import '../payment/process_payment.dart'; + class LoyaltyViewModel extends ChangeNotifier { final _authService = GetIt.instance(); final _profileService = GetIt.instance(); @@ -14,6 +15,7 @@ class LoyaltyViewModel extends ChangeNotifier { final _paymentProcessor = GetIt.instance(); double? userBalance; + double? userReward; String? userName; String? errorMessage; bool isLoading = true; @@ -41,6 +43,7 @@ class LoyaltyViewModel extends ChangeNotifier { userBalance = (data?['balance'] as num?)?.toDouble() ?? 0.0; userName = data?['full_name'] ?? 'John Doe'; + userReward = (data?["reward_tracker"] as num?)?.toDouble() ?? 0.0; } catch (_) { errorMessage = 'Failed to fetch balance'; } @@ -55,9 +58,10 @@ class LoyaltyViewModel extends ChangeNotifier { final thirtyDaysAgo = DateTime.now().subtract(const Duration(days: 30)); - final filtered = transactions.where((t) { - final createdAt = DateTime.parse(t['created_at'] as String); - return createdAt.isAfter(thirtyDaysAgo); + final filtered = transactions.where((transaction) { + final createdAt = DateTime.parse(transaction['created_at'] as String); + final type = transaction['type'] as String?; + return createdAt.isAfter(thirtyDaysAgo) && type != "Rewards"; }); recentTransactions = TransactionParser.formatTransactionsList( @@ -75,14 +79,16 @@ class LoyaltyViewModel extends ChangeNotifier { } Future loadCard(double amount) async { + final userId = _authService.getCurrentUserId; final result = await _paymentProcessor.processPayment( amount, "Loyalty Card", ); if (result == PaymentResult.success) { + amount = checkRewards(amount); final newBalance = (userBalance ?? 0) + amount; - await _profileService.updateBalanceById(newBalance); + await _profileService.updateBalanceById(userId!, newBalance); userBalance = newBalance; await _fetchTransactions(); } @@ -94,4 +100,18 @@ class LoyaltyViewModel extends ChangeNotifier { Future fetchTransactions() async { await _transactionService.getTransactionsForUser(); } -} + + double checkRewards(double amount) { + double combined = (userReward ?? 0) + amount; + double remainder = combined % 20; + int rewardsEarned = combined ~/ 20; + + if (remainder != (userReward ?? 0)) { + _profileService.updateRewardsById(_authService.getCurrentUserId!, remainder); + } + + userReward = remainder; + + return amount + (rewardsEarned * 5); + } +} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index a4a42aab..76d87776 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -101,9 +101,7 @@ Future setupDependencies() async { getIt.registerLazySingleton(() => LoyaltyViewModel()); - getIt.registerLazySingleton(() => PaymentProcessor( - paymentService: getIt(), - transactionService: getIt(),)); + getIt.registerLazySingleton(() => PaymentProcessor()); } class MyApp extends StatelessWidget { diff --git a/lib/middleware/app_router.dart b/lib/middleware/app_router.dart index d1c99c75..29da1173 100644 --- a/lib/middleware/app_router.dart +++ b/lib/middleware/app_router.dart @@ -1,6 +1,7 @@ import 'package:app_links/app_links.dart'; import 'package:clean_stream_laundry_app/pages/change_email_verification.dart'; import 'package:clean_stream_laundry_app/pages/edit_profile_page.dart'; +import 'package:clean_stream_laundry_app/pages/verify_code_page.dart'; import 'package:go_router/go_router.dart'; import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; import 'package:clean_stream_laundry_app/pages/email_verification_page.dart'; @@ -173,21 +174,33 @@ class RouterService { GoRoute( path: '/reset-protected', pageBuilder: (context, state) { - final uri = (state.extra as Uri?) ?? state.uri; return CustomTransitionPage( key: state.pageKey, - child: ResetProtectedPage(incomingUri: uri), + child: ResetProtectedPage(), transitionDuration: Duration.zero, reverseTransitionDuration: Duration.zero, transitionsBuilder: (_, _, _, child) => child, ); }, ), + GoRoute( + path: '/verify-code', + pageBuilder: (context, state) { + final email = state.extra as String; + return CustomTransitionPage( + key: state.pageKey, + child: CodeVerificationPage(email: email), + transitionDuration: Duration.zero, + reverseTransitionDuration: Duration.zero, + transitionsBuilder: (_, _, _, child) => child, + ); + }, + ) ], errorBuilder: (context, state) { final uri = state.uri; if (uri.scheme == 'clean-stream' && uri.host == 'reset-protected') { - return ResetProtectedPage(incomingUri: uri); + return ResetProtectedPage(); } return const NotFoundScreen(); }, @@ -200,12 +213,17 @@ class RouterService { return query.isEmpty ? '/reset-protected' : '/reset-protected?$query'; } + // Handle clean-stream://email-verification deep links + if (uri.scheme == 'clean-stream' && uri.host == 'email-verification') { + return '/email-verification'; + } + // Handle clean-stream://change-email deep links if (uri.scheme == 'clean-stream' && uri.host == 'change-email') { // Optional: check type query param final type = uri.queryParameters['type']; if (type == 'email_change' || type == null) { - return '/homePage'; + return '/editProfile'; } } diff --git a/lib/pages/change_email_verification.dart b/lib/pages/change_email_verification.dart index 247ece01..e357efcf 100644 --- a/lib/pages/change_email_verification.dart +++ b/lib/pages/change_email_verification.dart @@ -35,7 +35,7 @@ class _ChangeEmailVerificationPageState await authService.getCurrentUser(); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { - context.go('/homePage'); + context.go('/editProfile'); } }); } diff --git a/lib/pages/edit_profile_page.dart b/lib/pages/edit_profile_page.dart index 29ee9181..959fd78b 100644 --- a/lib/pages/edit_profile_page.dart +++ b/lib/pages/edit_profile_page.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/edge_function_service.dart'; import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; import 'package:clean_stream_laundry_app/widgets/status_dialog_box.dart'; @@ -8,7 +9,6 @@ import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; import 'package:flutter/services.dart'; - class EditProfilePage extends StatefulWidget { const EditProfilePage({super.key}); @@ -26,6 +26,7 @@ class _EditProfilePageState extends State { ); final profileService = GetIt.instance(); final authService = GetIt.instance(); + final edgeFunctionService = GetIt.instance(); String currentName = ''; String currentEmail = ''; @@ -105,18 +106,17 @@ class _EditProfilePageState extends State { final nameChanged = newName != currentName; final emailChanged = newEmail != currentEmail; - if (!nameChanged && !emailChanged) { statusDialog( context, title: "No Changes", - message: "You haven’t changed anything.", + message: "You haven't changed anything.", isSuccess: false, ); return; } - final confirmed = await _confirmationWindow(); + final confirmed = await _confirmSaveChanges(); if (!confirmed) return; if (!_formKey.currentState!.validate()) return; @@ -169,9 +169,13 @@ class _EditProfilePageState extends State { showDialog( context: context, builder: (context) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), title: Text( 'Error', - style: TextStyle(color: Theme.of(context).colorScheme.fontSecondary), + style: TextStyle( + color: Theme.of(context).colorScheme.fontSecondary, + fontWeight: FontWeight.bold, + ), ), content: Text( message, @@ -180,7 +184,10 @@ class _EditProfilePageState extends State { actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), - child: const Text('OK'), + child: Text( + 'OK', + style: TextStyle(color: Theme.of(context).colorScheme.primary), + ), ), ], ), @@ -191,7 +198,12 @@ class _EditProfilePageState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - backgroundColor: Theme.of(context).colorScheme.primary, + backgroundColor: Colors.transparent, + flexibleSpace: Container( + decoration: BoxDecoration( + gradient: Theme.of(context).colorScheme.primaryGradient, + ), + ), leading: IconButton( icon: const Icon(Icons.arrow_back, color: Colors.white), onPressed: () { @@ -200,7 +212,11 @@ class _EditProfilePageState extends State { ), title: const Text( "Edit Profile", - style: TextStyle(color: Colors.white), + style: TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.w600, + ), ), centerTitle: true, elevation: 0, @@ -211,192 +227,477 @@ class _EditProfilePageState extends State { color: Theme.of(context).colorScheme.primary, ), ) - : Padding( - padding: const EdgeInsets.all(16), - child: Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 24), - - // Current Name Display - if (currentName.isNotEmpty) - Text( - 'Current Name: $currentName', - style: TextStyle( - fontSize: 18, - color: Theme.of(context).colorScheme.fontSecondary, - ), - ), - const SizedBox(height: 8), - - TextFormField( - controller: _nameController, - enabled: !_isSaving, - inputFormatters: [ - LengthLimitingTextInputFormatter(36), - FilteringTextInputFormatter.allow( - RegExp(r'[a-zA-Z0-9 ]'), - ), - ], - - maxLength: 36, - style: TextStyle( - color: Theme.of(context).colorScheme.fontSecondary, + : SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 24, + ), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Name Section + _buildSectionHeader('Full Name'), + const SizedBox(height: 12), + + _buildInfoCard( + label: 'Current', + value: currentName.isNotEmpty ? currentName : 'Not set', + icon: Icons.badge_outlined, ), - decoration: InputDecoration( - labelText: 'Full Name', - labelStyle: TextStyle( - color: Theme.of(context).colorScheme.primary, - ), - counterStyle: TextStyle( + + const SizedBox(height: 16), + + TextFormField( + controller: _nameController, + enabled: !_isSaving, + inputFormatters: [ + LengthLimitingTextInputFormatter(36), + FilteringTextInputFormatter.allow( + RegExp(r'[a-zA-Z0-9 ]'), + ), + ], + maxLength: 36, + style: TextStyle( color: Theme.of(context).colorScheme.fontSecondary, + fontSize: 16, ), - contentPadding: const EdgeInsets.symmetric( - vertical: 10, - horizontal: 16, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - ), - focusedBorder: OutlineInputBorder( - borderSide: BorderSide( + decoration: InputDecoration( + labelText: 'New Full Name', + hintText: 'Enter your full name', + hintStyle: TextStyle( + color: Theme.of( + context, + ).colorScheme.fontSecondary.withValues(alpha: 0.5), + ), + labelStyle: TextStyle( color: Theme.of(context).colorScheme.primary, - width: 2.0, + fontSize: 14, ), - borderRadius: BorderRadius.circular(12), - ), - enabledBorder: OutlineInputBorder( - borderSide: BorderSide( - color: Theme.of(context).colorScheme.fontSecondary, + counterStyle: TextStyle( + color: Theme.of( + context, + ).colorScheme.fontSecondary.withValues(alpha: 0.6), + ), + filled: true, + fillColor: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.03), + contentPadding: const EdgeInsets.symmetric( + vertical: 16, + horizontal: 16, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.primary, + width: 2.0, + ), + borderRadius: BorderRadius.circular(14), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.fontSecondary + .withValues(alpha: 0.2), + ), + borderRadius: BorderRadius.circular(14), + ), + prefixIcon: Icon( + Icons.person_outline, + color: Theme.of(context).colorScheme.primary, ), - borderRadius: BorderRadius.circular(12), - ), - prefixIcon: Icon( - Icons.person, - color: Theme.of(context).colorScheme.primary, ), + textInputAction: TextInputAction.next, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Name cannot be empty'; + } + return null; + }, ), - textInputAction: TextInputAction.next, - validator: (value) { - if (value == null || value.trim().isEmpty) { - return 'Name cannot be empty'; - } - return null; - }, - ), - const SizedBox(height: 16), + const SizedBox(height: 10), - // Current Email Display - if (currentEmail.isNotEmpty) - Text( - 'Current Email: $currentEmail', - style: TextStyle( - fontSize: 18, - color: Theme.of(context).colorScheme.fontSecondary, - ), - ), - const SizedBox(height: 8), + // Email Section + _buildSectionHeader('Email Address'), + const SizedBox(height: 12), - TextFormField( - controller: _emailController, - enabled: !_isSaving, - style: TextStyle( - color: Theme.of(context).colorScheme.fontSecondary, + _buildInfoCard( + label: 'Current', + value: currentEmail.isNotEmpty + ? currentEmail + : 'Not set', + icon: Icons.email_outlined, ), - decoration: InputDecoration( - labelText: 'Email', - labelStyle: TextStyle( - color: Theme.of(context).colorScheme.primary, - ), - contentPadding: const EdgeInsets.symmetric( - vertical: 10, - horizontal: 16, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), + + const SizedBox(height: 16), + + TextFormField( + controller: _emailController, + enabled: !_isSaving, + style: TextStyle( + color: Theme.of(context).colorScheme.fontSecondary, + fontSize: 16, ), - focusedBorder: OutlineInputBorder( - borderSide: BorderSide( + decoration: InputDecoration( + labelText: 'New Email', + hintText: 'Enter your email address', + hintStyle: TextStyle( + color: Theme.of( + context, + ).colorScheme.fontSecondary.withValues(alpha: 0.5), + ), + labelStyle: TextStyle( color: Theme.of(context).colorScheme.primary, - width: 2.0, + fontSize: 14, ), - borderRadius: BorderRadius.circular(12), - ), - enabledBorder: OutlineInputBorder( - borderSide: BorderSide( - color: Theme.of(context).colorScheme.fontSecondary, + filled: true, + fillColor: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.03), + contentPadding: const EdgeInsets.symmetric( + vertical: 16, + horizontal: 16, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.primary, + width: 2.0, + ), + borderRadius: BorderRadius.circular(14), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.fontSecondary + .withValues(alpha: 0.2), + ), + borderRadius: BorderRadius.circular(14), + ), + prefixIcon: Icon( + Icons.email_outlined, + color: Theme.of(context).colorScheme.primary, ), - borderRadius: BorderRadius.circular(12), - ), - prefixIcon: Icon( - Icons.email, - color: Theme.of(context).colorScheme.primary, ), + keyboardType: TextInputType.emailAddress, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Email cannot be empty'; + } + if (!value.trim().contains("@")) { + return 'Please enter a valid email'; + } + return null; + }, ), - keyboardType: TextInputType.emailAddress, - validator: (value) { - if (value == null || value.trim().isEmpty) { - return 'Email cannot be empty'; - } - if (!value.trim().contains("@")) { - return 'Please enter a valid email'; - } - return null; - }, - ), - const SizedBox(height: 34), - Center( - child: SizedBox( - width: 200, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: Theme.of( - context, - ).colorScheme.primary, + const SizedBox(height: 20), + + // Save Button + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of( + context, + ).colorScheme.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), ), - onPressed: _isSaving ? null : _onSavePressed, - child: _isSaving - ? const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, + ), + onPressed: _isSaving ? null : _onSavePressed, + child: _isSaving + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.check_circle_outline, size: 20), + SizedBox(width: 8), + Text( + 'Save Changes', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), ), - ) - : const Text( - 'Save Changes', + ], + ), + ), + + const SizedBox(height: 30), + + // Delete Account Section + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: Colors.red.withValues(alpha: 0.2), + width: 1, + ), + ), + child: Column( + children: [ + Row( + children: [ + Icon( + Icons.warning_amber_rounded, + color: Colors.red, + size: 20, + ), + const SizedBox(width: 8), + Text( + 'Danger Zone', style: TextStyle( - color: Colors.white, - fontSize: 18, + color: Colors.red, + fontSize: 16, + fontWeight: FontWeight.w600, ), ), + ], + ), + const SizedBox(height: 12), + Text( + 'Once you delete your account, there is no going back. Any loyalty points will be permanently lost.', + style: TextStyle( + color: Theme.of(context) + .colorScheme + .fontSecondary + .withValues(alpha: 0.7), + fontSize: 13, + ), + ), + const SizedBox(height: 16), + OutlinedButton( + onPressed: _isSaving ? null : _deleteAccount, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.red, + side: const BorderSide( + color: Colors.red, + width: 1.5, + ), + padding: const EdgeInsets.symmetric( + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: _isSaving + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.red, + ), + ) + : Row( + mainAxisAlignment: + MainAxisAlignment.center, + children: const [ + Icon(Icons.delete_outline, size: 18), + SizedBox(width: 8), + Text( + 'Delete Account', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], ), ), - ), - ], + + const SizedBox(height: 24), + ], + ), ), ), ), ); } - Future _confirmationWindow() async { + Widget _buildSectionHeader(String title) { + return Text( + title, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.fontSecondary, + ), + ); + } + + Widget _buildInfoCard({ + required String label, + required String value, + required IconData icon, + }) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.2), + width: 1, + ), + ), + child: Row( + children: [ + Icon(icon, color: Theme.of(context).colorScheme.primary, size: 20), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 12, + color: Theme.of( + context, + ).colorScheme.fontSecondary.withValues(alpha: 0.6), + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + value, + style: TextStyle( + fontSize: 16, + color: Theme.of(context).colorScheme.fontSecondary, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ], + ), + ); + } + + void _deleteAccount() async { + bool confirm = await _confirmDeleteAccount(); + if (confirm) { + String? userId = authService.getCurrentUserId; + final response = await edgeFunctionService.runEdgeFunction( + name: "delete-account", + body: {"user_id": userId}, + ); + if (response!.status == 200) { + statusDialog( + context, + title: "Account Deleted", + message: "Your account has been deleted successfully.", + isSuccess: true, + ); + await authService.logout(); + context.go("/login"); + } else { + statusDialog( + context, + title: "Error", + message: "An error occurred, please try again later.", + isSuccess: false, + ); + return; + } + } else { + return; + } + } + + Future _confirmDeleteAccount() async { return await showDialog( context: context, builder: (context) => AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + title: Row( + children: [ + Icon(Icons.warning_amber_rounded, color: Colors.red), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Delete Account?', + style: TextStyle( + color: Theme.of(context).colorScheme.fontSecondary, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + content: Text( + 'Are you sure you want to delete your account? Any money on your loyalty card will be lost. This action cannot be undone.', + style: TextStyle( + color: Theme.of(context).colorScheme.fontSecondary, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text( + 'Cancel', + style: TextStyle( + color: Theme.of(context).colorScheme.fontSecondary, + ), + ), + ), + ElevatedButton( + onPressed: () => Navigator.of(context).pop(true), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + ), + child: const Text('Delete'), + ), + ], + ), + ) ?? + false; + } + + Future _confirmSaveChanges() async { + return await showDialog( + context: context, + builder: (context) => AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), title: Text( 'Confirm Changes', style: TextStyle( color: Theme.of(context).colorScheme.fontSecondary, + fontWeight: FontWeight.bold, ), ), content: Text( - 'Are you sure you want to change your information?', + 'Are you sure you want to save these changes to your profile?', style: TextStyle( color: Theme.of(context).colorScheme.fontSecondary, ), @@ -404,11 +705,20 @@ class _EditProfilePageState extends State { actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), - child: const Text('Cancel'), + child: Text( + 'Cancel', + style: TextStyle( + color: Theme.of(context).colorScheme.fontSecondary, + ), + ), ), ElevatedButton( onPressed: () => Navigator.of(context).pop(true), - child: const Text('Yes, Save'), + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.primary, + foregroundColor: Colors.white, + ), + child: const Text('Save'), ), ], ), diff --git a/lib/pages/email_verification_page.dart b/lib/pages/email_verification_page.dart index 96b3a705..407bb452 100644 --- a/lib/pages/email_verification_page.dart +++ b/lib/pages/email_verification_page.dart @@ -33,10 +33,11 @@ class _EmailVerificationPageState extends State { }); // Handles app links - _linkSub = widget.appLinks.uriLinkStream.listen((Uri? uri) { + _linkSub = widget.appLinks.uriLinkStream.listen((Uri? uri) async { if (uri != null && uri.scheme == 'clean-stream' && uri.host == 'email-verification') { + await authService.getSessionFromURI(uri); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { context.go('/homePage'); diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 8214392d..e3a59eb8 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -7,10 +7,13 @@ import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; import 'package:clean_stream_laundry_app/middleware/storage_service.dart'; import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:url_launcher/url_launcher.dart'; class HomePage extends StatefulWidget { const HomePage({super.key}); @@ -31,7 +34,6 @@ class HomePageState extends State { late StorageService storage; late final MapController _mapController; - final authService = GetIt.instance(); final profileService = GetIt.instance(); @@ -66,6 +68,41 @@ class HomePageState extends State { } } + Future _openDirectionsFromAddress(String? address) async { + if (address == null) return; + + final encodedAddress = Uri.encodeComponent(address); + Uri uri; + + if (kIsWeb) { + // Web fallback + uri = Uri.parse( + 'https://www.google.com/maps/dir/?api=1&destination=$encodedAddress', + ); + } else { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + uri = Uri.parse('google.navigation:q=$encodedAddress'); + break; + case TargetPlatform.iOS: + uri = Uri.parse( + 'http://maps.apple.com/?daddr=$encodedAddress&dirflg=d', + ); + break; + default: + uri = Uri.parse( + 'https://www.google.com/maps/dir/?api=1&destination=$encodedAddress', + ); + } + } + + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } else { + throw 'Could not open maps'; + } + } + void _loadUserData() async { final userId = authService.getCurrentUserId; if (userId == null) return; @@ -83,21 +120,20 @@ class HomePageState extends State { final machineService = GetIt.instance(); final locationService = GetIt.instance(); + final locationParser = LocationParser(); @override Widget build(BuildContext context) { return BasePage( key: HomePage.pageKey, body: Padding( - padding: const EdgeInsets.all(4.0), + padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 2.0), child: SingleChildScrollView( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( - username == null - ? "Welcome!" - : "Welcome $username!", + username == null ? "Welcome!" : "Welcome $username!", style: TextStyle( fontWeight: FontWeight.bold, @@ -105,16 +141,76 @@ class HomePageState extends State { color: Theme.of(context).colorScheme.fontInverted, ), ), - const SizedBox(height: 2), - Text( - "Current balance: \$${balance?["balance"] ?? 'Loading...'}", - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 15, - color: Theme.of(context).colorScheme.fontInverted, - ), - ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + child: Text( + 'Current balance: \$${balance?["balance"] != null ? (balance!["balance"] as num).toStringAsFixed(2) : 'Loading...'}', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 15, + color: Theme.of(context).colorScheme.fontInverted, + ), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 12), + InkWell( + onTap: () async { + final locations = await locationService.getLocations(); + final nearest = await locationParser.getNearestLocation( + locations, + ); + if (nearest != null) { + final address = nearest["Address"] as String; + setState(() { + selectedName = address; + locationSelected = true; + locationIDSelected = locationID[address]; + }); + storage.setValue("lastSelectedLocation", address); + _zoomToLocation(address); + } + }, + borderRadius: BorderRadius.circular(14), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "Nearest Location", + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, + decoration: TextDecoration.underline, + decorationColor: Theme.of( + context, + ).colorScheme.primary, + ), + ), + const SizedBox(width: 6), + SvgPicture.asset( + "assets/locationPin.svg", + width: 24, + height: 24, + colorFilter: ColorFilter.mode( + Theme.of(context).colorScheme.primary, + BlendMode.srcIn, + ), + ), + ], + ), + ), + ), + ], + ), const SizedBox(height: 10), FutureBuilder( @@ -122,10 +218,10 @@ class HomePageState extends State { builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return Container( - height: 400, + height: 300, width: 400, decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(14), border: Border.all( color: Colors.grey.shade400, width: 1, @@ -153,10 +249,10 @@ class HomePageState extends State { double initialZoom = 7.2; return Container( - height: 400, - width: 400, + height: 300, + width: double.infinity, decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(14), border: Border.all(color: Colors.grey.shade400, width: 1), ), clipBehavior: Clip.antiAlias, @@ -166,13 +262,14 @@ class HomePageState extends State { initialCenter: initialCenter, initialZoom: initialZoom, keepAlive: true, + maxZoom: 15, ), children: [ TileLayer( urlTemplate: - 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', userAgentPackageName: - 'https://cleanstreamlaundry.com/', + 'https://cleanstreamlaundry.com/', tileProvider: NetworkTileProvider(), ), MarkerLayer(markers: markers), @@ -182,29 +279,29 @@ class HomePageState extends State { }, ), Container( - margin: EdgeInsets.only(top: 20), - padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4), + margin: EdgeInsets.only(top: 12), + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(14), border: Border.all(color: Colors.grey.shade400, width: 1), color: Theme.of(context).colorScheme.cardSecondary, ), child: Row( children: [ - Icon(Icons.location_on, color: Colors.blue, size: 28), + Icon(Icons.location_on, color: Colors.blue, size: 24), SizedBox(width: 8), Expanded( child: FutureBuilder( - future: Future.wait([locationService.getLocations()]), + future: locationService.getLocations(), builder: (context, snapshot) { - if (snapshot.connectionState == - ConnectionState.waiting) { - return const Center( - child: CircularProgressIndicator(), + if (snapshot.connectionState == ConnectionState.waiting) { + return const SizedBox( + height: 24, + child: Center(child: CircularProgressIndicator(strokeWidth: 2)), ); } - final data = snapshot.data![0]; + final data = snapshot.data!; for (var item in data) { locationID[item["Address"]] = item["id"]; } @@ -221,66 +318,81 @@ class HomePageState extends State { }); } - return DropdownButtonHideUnderline( - child: DropdownButton( - isExpanded: true, - value: locationID.containsKey(selectedName) - ? selectedName - : null, - hint: FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.centerLeft, - child: Text( - "Select Location", - style: TextStyle( - fontSize: 18, - color: Theme.of( - context, - ).colorScheme.fontInverted, - ), + return GestureDetector( + onTap: () { + showModalBottomSheet( + context: context, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), - ), - onChanged: (String? newValue) { - if (newValue != null) { - storage.setValue( - "lastSelectedLocation", - newValue, - ); - _zoomToLocation(newValue); - } - setState(() { - selectedName = newValue; - locationSelected = true; - locationIDSelected = locationID[newValue]; - }); - }, - items: locationID.entries.map((entry) { - return DropdownMenuItem( - value: entry.key, - child: FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.centerLeft, - child: Text( - entry.key, - style: TextStyle( - fontSize: 18, - color: Theme.of( - context, - ).colorScheme.fontInverted, + builder: (_) => ListView.separated( + padding: EdgeInsets.symmetric(vertical: 12), + itemCount: data.length, + separatorBuilder: (_, __) => Divider(height: 1), + itemBuilder: (_, index) { + final item = data[index]; + return ListTile( + title: Text( + item["Address"], + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: TextStyle( + fontSize: 16, + color: Theme.of(context).colorScheme.fontInverted, + ), ), - ), - ), - ); - }).toList(), + onTap: () { + setState(() { + selectedName = item["Address"]; + locationSelected = true; + locationIDSelected = item["id"]; + }); + storage.setValue("lastSelectedLocation", selectedName!); + _zoomToLocation(selectedName!); + Navigator.pop(context); + }, + ); + }, + ), + ); + }, + child: Container( + padding: EdgeInsets.symmetric(vertical: 4), + child: Text( + selectedName ?? "Select Location", + style: TextStyle( + fontSize: 16, + color: selectedName == null + ? Colors.grey + : Theme.of(context).colorScheme.fontInverted, + ), + overflow: TextOverflow.ellipsis, + ), ), ); }, ), ), + IconButton( + onPressed: () async { + if (selectedName != null) { + await _openDirectionsFromAddress(selectedName); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Please select a location to get directions!")), + ); + } + }, + icon: Icon(Icons.navigation, color: Theme.of(context).primaryColor, size: 24), + padding: EdgeInsets.zero, // remove extra padding + constraints: BoxConstraints(), + ), ], ), ), - SizedBox(height: 10), + + SizedBox(height: 14), + if (locationSelected) FutureBuilder( future: Future.wait([ @@ -308,10 +420,10 @@ class HomePageState extends State { final idleDryers = snapshot.data![3]; return Container( - width: 520, + width: double.infinity, decoration: BoxDecoration( border: Border.all(color: Colors.blue, width: 3), - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(14), color: Colors.transparent, ), child: Column( @@ -339,82 +451,95 @@ class HomePageState extends State { ), ), ), - SizedBox( - height: 80, - child: Row( - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Flexible( - child: FittedBox( - fit: BoxFit.scaleDown, - child: Text( - "$idleWashers/$totalWashers Washers", - style: TextStyle( - color: Theme.of(context).colorScheme.fontSecondary, - fontSize: 20, - fontWeight: FontWeight.bold, + SizedBox( + height: 80, + child: Row( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 4.0, + ), + child: Row( + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + Flexible( + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + "$idleWashers/$totalWashers Washers", + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.fontSecondary, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), ), ), - ), - ), - const SizedBox(width: 8), - const Icon( - Icons.local_laundry_service, - color: Colors.blue, - size: 36, + const SizedBox(width: 8), + const Icon( + Icons.local_laundry_service, + color: Colors.blue, + size: 36, + ), + ], ), - ], + ), ), - ), - ), - Container(width: 2, color: Colors.blue), - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Flexible( - child: FittedBox( - fit: BoxFit.scaleDown, - child: Text( - "$idleDryers/$totalDryers Dryers", - style: TextStyle( - color: Theme.of(context).colorScheme.fontSecondary, - fontSize: 20, - fontWeight: FontWeight.bold, + Container(width: 2, color: Colors.blue), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 4.0, + ), + child: Row( + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + Flexible( + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + "$idleDryers/$totalDryers Dryers", + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.fontSecondary, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), ), ), - ), - ), - const SizedBox(width: 8), - const Icon( - Icons.local_laundry_service, - color: Colors.blue, - size: 36, + const SizedBox(width: 8), + const Icon( + Icons.local_laundry_service, + color: Colors.blue, + size: 36, + ), + ], ), - ], + ), ), - ), + ], ), - ], - ), + ), + ], ), - ], - ), - ); - }, - ), + ); + }, + ), + + SizedBox(height: 12,) + ], ), ), ), ); } -} +} \ No newline at end of file diff --git a/lib/pages/loading_page.dart b/lib/pages/loading_page.dart index 00a0a000..74d96a2c 100644 --- a/lib/pages/loading_page.dart +++ b/lib/pages/loading_page.dart @@ -42,11 +42,12 @@ class _LoadingPageState extends State { initialUri.host == 'email-verification') { context.go("/homePage"); } else if (initialUri != null && initialUri.host == 'change-email') { + context.go("/email-verification"); } else if (initialUri != null && initialUri.scheme == 'clean-stream' && initialUri.host == 'oauth') { - await authService.handleOAuthRedirect(initialUri); + await authService.getSessionFromURI(initialUri); if (await authService.isLoggedIn() == AuthenticationResponses.success) { context.go("/homePage"); } else { @@ -83,58 +84,58 @@ class _LoadingPageState extends State { return Center( child: _error != null ? Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.error_outline, color: Colors.redAccent, size: 80), - const SizedBox(height: 20), - Text( - 'Authentication Failed', - style: const TextStyle( - color: Colors.white, - fontSize: 22, - fontWeight: FontWeight.bold, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 10), - Text( - _error!, - style: TextStyle( - color: Colors.redAccent.withValues(alpha: 0.8), - fontSize: 14, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 26), - ElevatedButton.icon( - onPressed: () => context.go("/login"), - icon: const Icon(Icons.login), - label: const Text('Return to Login'), - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 24, - vertical: 12, - ), - ), - ), - ], - ) - : TweenAnimationBuilder( - tween: Tween(begin: begin, end: end), - duration: const Duration(seconds: 1), - curve: Curves.easeInOut, - builder: (context, scale, child) { - return Transform.scale(scale: scale, child: child); - }, - child: Image.asset("assets/Logo.png", height: 250), - onEnd: () { - setState(() { - double temp = begin; - begin = end; - end = temp; - }); - }, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, color: Colors.redAccent, size: 80), + const SizedBox(height: 20), + Text( + 'Authentication Failed', + style: const TextStyle( + color: Colors.white, + fontSize: 22, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 10), + Text( + _error!, + style: TextStyle( + color: Colors.redAccent.withValues(alpha: 0.8), + fontSize: 14, ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 26), + ElevatedButton.icon( + onPressed: () => context.go("/login"), + icon: const Icon(Icons.login), + label: const Text('Return to Login'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + ), + ), + ], + ) + : TweenAnimationBuilder( + tween: Tween(begin: begin, end: end), + duration: const Duration(seconds: 1), + curve: Curves.easeInOut, + builder: (context, scale, child) { + return Transform.scale(scale: scale, child: child); + }, + child: Image.asset("assets/Logo.png", height: 250), + onEnd: () { + setState(() { + double temp = begin; + begin = end; + end = temp; + }); + }, + ), ); } -} +} \ No newline at end of file diff --git a/lib/pages/login_page.dart b/lib/pages/login_page.dart index fcbdc23c..0b3980e2 100644 --- a/lib/pages/login_page.dart +++ b/lib/pages/login_page.dart @@ -51,7 +51,7 @@ class _LoginScreenState extends State { if (uri.scheme == 'clean-stream' && uri.host == 'email-verification') { context.go("/homePage"); } else if (uri.scheme == 'clean-stream' && uri.host == 'oauth') { - await authService.handleOAuthRedirect(uri); + await authService.getSessionFromURI(uri); if (await authService.isLoggedIn() == AuthenticationResponses.success) { if (!mounted) return; diff --git a/lib/pages/loyalty_card_page.dart b/lib/pages/loyalty_card_page.dart index 93fb1f6c..77a8227b 100644 --- a/lib/pages/loyalty_card_page.dart +++ b/lib/pages/loyalty_card_page.dart @@ -44,45 +44,67 @@ class LoyaltyCardPage extends State { } Widget _buildContent(BuildContext context) { - return SingleChildScrollView( - child: Column( - children: [ - const SizedBox(height: 20), - CreditCard(username: viewModel.userName ?? 'John Doe'), - const SizedBox(height: 50), - Text( - 'Current Balance: \$${viewModel.userBalance?.toStringAsFixed(2) ?? '0.00'}', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 26, - fontWeight: FontWeight.w500, - color: Theme.of(context).colorScheme.fontSecondary, - ), + return Column( + children: [ + const SizedBox(height: 10), + CreditCard(username: viewModel.userName ?? 'John Doe'), + const SizedBox(height: 17), + Text( + 'Loyalty Balance: \$${viewModel.userBalance?.toStringAsFixed(2) ?? '0.00'}', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 26, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.fontSecondary, ), - const SizedBox(height: 25), - ElevatedButton( - onPressed: () => _loadCard(), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.blue, - disabledBackgroundColor: Colors.grey, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - elevation: 2, - ), - child: const Text( - "Load card", + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '\$${(20 - (viewModel.userReward ?? 0)).toStringAsFixed(2)} until next reward', + textAlign: TextAlign.center, style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.fontSecondary, ), ), + const SizedBox(width: 4), + IconButton( + onPressed: () => _showRewardInfoDialog(context), + icon: const Icon(Icons.info_outline), + iconSize: 18, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + color: Colors.blue, + ), + ], + ), + const SizedBox(height: 7), + ElevatedButton( + onPressed: () => _loadCard(), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue, + disabledBackgroundColor: Colors.grey, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + elevation: 2, ), - const SizedBox(height: 20), - _transactions(), - ], - ), + child: const Text( + "Load card", + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + const SizedBox(height: 15), + Expanded(child: _transactions()), + ], ); } @@ -96,8 +118,9 @@ class LoyaltyCardPage extends State { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: ListView( + cacheExtent: 1000, + physics: const AlwaysScrollableScrollPhysics(), children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -125,36 +148,30 @@ class LoyaltyCardPage extends State { ), ], ), - const SizedBox(height: 10), - ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: viewModel.recentTransactions.length, - itemBuilder: (context, index) { - final transaction = viewModel.recentTransactions[index]; - return Card( - margin: const EdgeInsets.symmetric( - horizontal: 4.0, - vertical: 6.0, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), + const SizedBox(height: 9), + ...viewModel.recentTransactions.map((transaction) { + return Card( + margin: const EdgeInsets.symmetric( + horizontal: 4.0, + vertical: 6.0, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + elevation: 4, + color: Theme.of(context).colorScheme.cardPrimary, + child: ListTile( + leading: const Icon( + Icons.receipt_long, + color: Color(0xFF2073A9), ), - elevation: 4, - color: Theme.of(context).colorScheme.cardPrimary, - child: ListTile( - leading: const Icon( - Icons.receipt_long, - color: Color(0xFF2073A9), - ), - title: Text( - transaction.toString(), - style: const TextStyle(fontSize: 14, color: Colors.black87), - ), + title: Text( + transaction.toString(), + style: const TextStyle(fontSize: 14, color: Colors.black87), ), - ); - }, - ), + ), + ); + }), ], ), ); @@ -419,7 +436,6 @@ class LoyaltyCardPage extends State { } if (result == PaymentResult.success) { - viewModel.fetchTransactions(); statusDialog( @@ -446,4 +462,27 @@ class LoyaltyCardPage extends State { ); } } + + void _showRewardInfoDialog(BuildContext context) { + showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + title: const Text('Rewards program'), + content: const Text( + 'For every \$20 you spend, you get an extra \$5 automatically added to your loyalty balance.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('Got it'), + ), + ], + ); + }, + ); + } } diff --git a/lib/pages/monthly_transaction_history.dart b/lib/pages/monthly_transaction_history.dart index 03fe0e43..4a9b75db 100644 --- a/lib/pages/monthly_transaction_history.dart +++ b/lib/pages/monthly_transaction_history.dart @@ -4,13 +4,29 @@ import 'package:clean_stream_laundry_app/logic/parsing/transaction_parser.dart'; import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; import 'package:go_router/go_router.dart'; -class MonthlyTransactionHistory extends StatelessWidget { +class MonthlyTransactionHistory extends StatefulWidget { final List> transactions; const MonthlyTransactionHistory({super.key, required this.transactions}); + @override + State createState() => + _MonthlyTransactionHistoryState(); +} + +class _MonthlyTransactionHistoryState extends State { + int? _selectedYear; + @override Widget build(BuildContext context) { - final monthlySums = TransactionParser.getMonthlySums(transactions); + final colorScheme = Theme.of(context).colorScheme; + final cardBackgroundColor = colorScheme.cardPrimary; + final cardTextColor = + ThemeData.estimateBrightnessForColor(cardBackgroundColor) == + Brightness.dark + ? Colors.white + : Colors.black; + + final monthlySums = TransactionParser.getMonthlySums(widget.transactions); final sortedMonths = monthlySums.keys.toList() ..sort((a, b) { final dateA = DateFormat('MMM yyyy').parse(a); @@ -18,59 +34,126 @@ class MonthlyTransactionHistory extends StatelessWidget { return dateB.compareTo(dateA); }); - final ScrollController _scrollController = ScrollController(); + final availableYears = + sortedMonths + .map((month) => DateFormat('MMM yyyy').parse(month).year) + .toSet() + .toList() + ..sort((a, b) => b.compareTo(a)); - return Scaffold( - appBar: AppBar( - leading: IconButton( - icon: Icon( - Icons.arrow_back, - color: Colors.white, - ), - onPressed: () => context.pop(), - ), - backgroundColor: Theme.of(context).colorScheme.primary, - title: Text( - 'Monthly Transaction History', - style: TextStyle(color: Colors.white), + if (availableYears.isEmpty) { + availableYears.add(DateTime.now().year); + } + + final selectedYear = + (_selectedYear != null && availableYears.contains(_selectedYear)) + ? _selectedYear! + : availableYears.first; + + final filteredMonths = sortedMonths.where((month) { + final date = DateFormat('MMM yyyy').parse(month); + return date.year == selectedYear; + }).toList(); + + Future showYearPickerSheet() async { + await showModalBottomSheet( + context: context, + backgroundColor: cardBackgroundColor, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), - elevation: 2, - centerTitle: true, - ), - body: Scaffold( - body: Theme( - data: Theme.of(context).copyWith( - scrollbarTheme: ScrollbarThemeData( - thumbColor: WidgetStateProperty.all(Colors.lightBlue), - trackColor: WidgetStateProperty.all(Colors.transparent), - thickness: WidgetStateProperty.all(8), - radius: const Radius.circular(4), + builder: (sheetContext) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + title: Text( + 'Year: $selectedYear', + style: TextStyle( + fontWeight: FontWeight.bold, + color: cardTextColor, + ), + ), + ), + const Divider(height: 1), + ...availableYears.map( + (year) => ListTile( + key: ValueKey('year-option-$year'), + title: Text( + year.toString(), + style: TextStyle(color: cardTextColor), + ), + trailing: year == selectedYear + ? Icon(Icons.check, color: colorScheme.primary) + : null, + onTap: () { + Navigator.of(sheetContext).pop(); + if (year == selectedYear) return; + setState(() { + _selectedYear = year; + }); + }, + ), + ), + ], + ), ), + ); + }, + ); + } + + Widget buildMonthList(List visibleMonths) { + final ScrollController scrollController = ScrollController(); + + if (visibleMonths.isEmpty) { + return const Center( + child: Text( + 'No transactions found for this time range.', + style: TextStyle(fontSize: 16), + ), + ); + } + + return Theme( + data: Theme.of(context).copyWith( + scrollbarTheme: ScrollbarThemeData( + thumbColor: WidgetStateProperty.all(colorScheme.primary), + trackColor: WidgetStateProperty.all(Colors.transparent), + thickness: WidgetStateProperty.all(8), + radius: const Radius.circular(4), ), - child: Scrollbar( - controller: _scrollController, - thumbVisibility: true, - child: ListView.builder( - controller: _scrollController, - padding: const EdgeInsets.all(16), - itemCount: sortedMonths.length, - itemBuilder: (context, index) { - final month = sortedMonths[index]; - final data = monthlySums[month]!; - final total = - data['directWasher']! + - data['directDryer']! + - data['loyaltyCard']!; - if (total == 0 && data['loyaltyWasher']==0 && data['loyaltyDryer']==0) { - return SizedBox(width: 0, height: 0,); - } else { + ), + child: Scrollbar( + controller: scrollController, + thumbVisibility: true, + interactive: true, + child: ListView.builder( + controller: scrollController, + padding: const EdgeInsets.all(16), + itemCount: visibleMonths.length, + itemBuilder: (context, index) { + final month = visibleMonths[index]; + final data = monthlySums[month]!; + final total = + data['directWasher']! + + data['directDryer']! + + data['loyaltyCard']!; + if (total == 0 && + data['loyaltyWasher'] == 0 && + data['loyaltyDryer'] == 0) { + return const SizedBox(width: 0, height: 0); + } else { return Card( margin: const EdgeInsets.only(bottom: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), elevation: 2, - color: Theme - .of(context) - .colorScheme - .cardPrimary, + color: cardBackgroundColor, child: Padding( padding: const EdgeInsets.all(16), child: Column( @@ -81,18 +164,18 @@ class MonthlyTransactionHistory extends StatelessWidget { children: [ Text( month, - style: const TextStyle( + style: TextStyle( fontSize: 20, fontWeight: FontWeight.bold, - color: Colors.black, + color: cardTextColor, ), ), Text( '\$${total.toStringAsFixed(2)}', - style: const TextStyle( + style: TextStyle( fontSize: 20, fontWeight: FontWeight.bold, - color: Colors.black, + color: cardTextColor, ), ), ], @@ -101,48 +184,74 @@ class MonthlyTransactionHistory extends StatelessWidget { _buildTransactionRow( 'Direct Washer Payments', data['directWasher']!, - Colors.black, + cardTextColor, ), const SizedBox(height: 8), _buildTransactionRow( 'Loyalty Washer Payments', data['loyaltyWasher']!, - Theme - .of(context) - .colorScheme - .primary, + colorScheme.primary, ), const SizedBox(height: 8), _buildTransactionRow( 'Direct Dryer Payments', data['directDryer']!, - Colors.black, + cardTextColor, ), const SizedBox(height: 8), _buildTransactionRow( 'Loyalty Dryer Payments', data['loyaltyDryer']!, - Theme - .of(context) - .colorScheme - .primary, + colorScheme.primary, ), const SizedBox(height: 8), _buildTransactionRow( 'Loyalty Card Loads', data['loyaltyCard']!, - Colors.black, + cardTextColor, ), ], ), ), ); } - }, - ), + }, + ), + ), + ); + } + + return Scaffold( + appBar: AppBar( + backgroundColor: Colors.transparent, + flexibleSpace: Container( + decoration: BoxDecoration(gradient: colorScheme.primaryGradient), + ), + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.white), + onPressed: () => context.pop(), + ), + title: const Text( + 'Monthly Transaction History', + style: TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.w600, ), ), + centerTitle: true, + elevation: 2, + actions: [ + IconButton( + key: const ValueKey('year-filter-button'), + onPressed: showYearPickerSheet, + icon: const Icon(Icons.filter_list, color: Colors.white), + tooltip: 'Filter by year', + ), + const SizedBox(width: 8), + ], ), + body: buildMonthList(filteredMonths), ); } diff --git a/lib/pages/password_reset.dart b/lib/pages/password_reset.dart index fdc9f292..5a08f7c4 100644 --- a/lib/pages/password_reset.dart +++ b/lib/pages/password_reset.dart @@ -46,6 +46,7 @@ class _PasswordResetPageState extends State { if (response == AuthenticationResponses.success) { _showMessage('Password reset email sent! Check your email.'); + context.go("/verify-code", extra: _emailController.text.trim()); } else { _showMessage('Failed to send reset email.'); } @@ -151,7 +152,9 @@ class _PasswordResetPageState extends State { : SizedBox( width: double.infinity, child: ElevatedButton( - onPressed: _sendResetEmail, + onPressed: () { + _sendResetEmail(); + }, style: ElevatedButton.styleFrom( backgroundColor: scheme.primary, foregroundColor: scheme.onPrimary, diff --git a/lib/pages/payment_page.dart b/lib/pages/payment_page.dart index eff22786..3ef32f2e 100644 --- a/lib/pages/payment_page.dart +++ b/lib/pages/payment_page.dart @@ -13,6 +13,8 @@ import 'package:clean_stream_laundry_app/logic/services/machine_communication_se import 'package:clean_stream_laundry_app/services/notification_service.dart'; import 'package:clean_stream_laundry_app/logic/enums/payment_result_enum.dart'; import 'package:go_router/go_router.dart'; +import 'package:clean_stream_laundry_app/widgets/washer_controls_card.dart'; +import 'package:clean_stream_laundry_app/widgets/dryer_controls_card.dart'; class PaymentPage extends StatefulWidget { final String machineId; @@ -31,6 +33,10 @@ class _PaymentPageState extends State { double? _userBalance; bool _isLoading = true; + double _basePrice = 0; + double _addedWasherCost = 0; + int _dryerMinutes = 5; + final machineService = GetIt.instance(); final profileService = GetIt.instance(); final authService = GetIt.instance(); @@ -39,6 +45,10 @@ class _PaymentPageState extends State { final notificationService = GetIt.instance(); final paymentProcessor = GetIt.instance(); + bool get _isDryer => + _machineName != null && + _machineName!.toLowerCase().contains('dryer'); + @override void initState() { super.initState(); @@ -49,20 +59,21 @@ class _PaymentPageState extends State { final data = await machineService.getMachineById(widget.machineId); final userId = authService.getCurrentUserId; - if (userId == null) { - return; - } + if (userId == null) return; + final balance = await profileService.getUserBalanceById(userId); if (data != null && balance != null) { + final name = data['Name'] as String?; + _basePrice = (data['Price'] as num).toDouble(); + setState(() { _userBalance = (balance['balance'] as num).toDouble(); - _machineName = data['Name']; - _price = (data['Price'] as num).toDouble(); + _machineName = name; + _price = _basePrice; _isLoading = false; }); } else { - // handle error / machine not found setState(() { _userBalance = 0; _machineName = 'Unknown'; @@ -72,13 +83,27 @@ class _PaymentPageState extends State { } } + void _onDryerChanged(double price, int minutes) { + setState(() { + _price = price; + _dryerMinutes = minutes; + }); + } + + void _onWasherCycleChanged(double addedCost) { + setState(() { + _addedWasherCost = addedCost; + _price = _basePrice + _addedWasherCost; + }); + } + Future makeNotification(String name) async { final notificationService = GetIt.instance(); await notificationService.scheduleEarlyMachineNotification( id: 1, - //This is where we would add code to get the machine finish time - //Use the machine finish time instead of hardcoding 5 mins - machineTime: const Duration(minutes: 5, seconds: 5), + machineTime: _isDryer + ? Duration(minutes: _dryerMinutes) + : const Duration(minutes: 5, seconds: 5), machineName: name, ); } @@ -89,34 +114,40 @@ class _PaymentPageState extends State { body: _isLoading ? const Center(child: CircularProgressIndicator()) : Column( - children: [ - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const SizedBox(height: 20), - const SizedBox(height: 40), - _buildAmountCard(), - const SizedBox(height: 30), - ], + children: [ + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _buildAmountCard(), + + const SizedBox(height: 20), + + if (_isDryer) + DryerControlsCard(onChanged: _onDryerChanged) + else + WasherControlsCard( + onCycleChanged: _onWasherCycleChanged, ), - ), - ), - Padding( - padding: const EdgeInsets.all(24.0), - child: _paymentCompleted - ? _buildBackToHomeButton( - context, - ) // Show this when payment is complete - : _buildPaymentButtons(context), // Show this otherwise - ), - ], + const SizedBox(height: 30), + ], + ), ), + ), + Padding( + padding: const EdgeInsets.all(24.0), + child: _paymentCompleted + ? _buildBackToHomeButton(context) + : _buildPaymentButtons(context), + ), + ], + ), ); } + Widget _buildAmountCard() { return Container( padding: const EdgeInsets.all(30), @@ -126,7 +157,11 @@ class _PaymentPageState extends State { ), child: Column( children: [ - Icon(Icons.local_laundry_service, size: 80, color: Color(0xFF2073A9)), + Icon( + Icons.local_laundry_service, + size: 80, + color: Color(0xFF2073A9), + ), const SizedBox(height: 20), Text( 'Machine $_machineName', @@ -157,13 +192,11 @@ class _PaymentPageState extends State { return SizedBox( width: double.infinity, child: ElevatedButton( - onPressed: () { - context.go('/homePage'); - }, + onPressed: () => context.go('/homePage'), style: ElevatedButton.styleFrom( backgroundColor: Colors.blue[700], shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(14), ), elevation: 2, padding: const EdgeInsets.symmetric(vertical: 16), @@ -183,7 +216,6 @@ class _PaymentPageState extends State { Widget _buildPaymentButtons(BuildContext context) { return Row( children: [ - // Stripe payment button Expanded( child: ElevatedButton( onPressed: (_isConfirmed || _price == null || _price == 0) @@ -192,8 +224,7 @@ class _PaymentPageState extends State { final success = await paymentProcessor.processPayment( _price!, MachineFormatter.formatMachineType( - _machineName.toString(), - ), + _machineName.toString()), ); if (success == PaymentResult.success) { @@ -205,17 +236,14 @@ class _PaymentPageState extends State { ); final deviceAuthorized = - await machineCommunicator.wakeDevice(widget.machineId); + await machineCommunicator.wakeDevice( + widget.machineId); Navigator.of(context, rootNavigator: true).pop(); if (deviceAuthorized) { - setState(() { - _paymentCompleted = true; - }); - - makeNotification(_machineName.toString()); - + setState(() => _paymentCompleted = true); + await makeNotification(_machineName.toString()); statusDialog( context, title: "Payment Processed! Machine Ready!", @@ -226,7 +254,8 @@ class _PaymentPageState extends State { statusDialog( context, title: "Machine Error", - message: "Payment succeeded but machine did not wake up.", + message: + "Payment succeeded but machine did not wake up.", isSuccess: false, ); } @@ -240,61 +269,59 @@ class _PaymentPageState extends State { } }, style: ElevatedButton.styleFrom( - backgroundColor: (_isConfirmed || _price == null || _price == 0) + backgroundColor: + (_isConfirmed || _price == null || _price == 0) ? Colors.grey : Colors.blue[700], disabledBackgroundColor: Colors.grey, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(14), ), elevation: 2, padding: const EdgeInsets.symmetric(vertical: 16), ), child: _isConfirmed ? const SizedBox( - height: 24, - width: 24, - child: CircularProgressIndicator( - color: Colors.white, - strokeWidth: 2.5, - ), - ) + height: 24, + width: 24, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2.5, + ), + ) : Text( - _price != null && _price! > 0 - ? 'Pay \$${_price!.toStringAsFixed(2)}' - : 'Pay', - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), + _price != null && _price! > 0 + ? 'Pay \$${_price!.toStringAsFixed(2)}' + : 'Pay', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), ), ), const SizedBox(width: 16), - // Loyalty payment button Expanded( child: ElevatedButton( - onPressed: - (_isConfirmed || - _price == null || - _price == 0 || - (_userBalance ?? 0) < (_price ?? 0)) + onPressed: (_isConfirmed || + _price == null || + _price == 0 || + (_userBalance ?? 0) < (_price ?? 0)) ? null : () => _processLoyaltyPayment(context), style: ElevatedButton.styleFrom( - backgroundColor: - (_isConfirmed || - _price == null || - _price == 0 || - (_userBalance ?? 0) < (_price ?? 0)) + backgroundColor: (_isConfirmed || + _price == null || + _price == 0 || + (_userBalance ?? 0) < (_price ?? 0)) ? Colors.grey : Colors.green[700], disabledBackgroundColor: Colors.grey, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(14), ), elevation: 2, padding: const EdgeInsets.symmetric(vertical: 16), @@ -314,46 +341,63 @@ class _PaymentPageState extends State { } void _processLoyaltyPayment(BuildContext context) async { + final userId = authService.getCurrentUserId; + final updatedBalance = _userBalance! - _price!; - profileService.updateBalanceById(updatedBalance); + setState(() => _userBalance = updatedBalance); - setState(() { - _userBalance = updatedBalance; - }); showDialog( context: context, barrierDismissible: false, builder: (BuildContext dialogContext) => const Center(child: CircularProgressIndicator()), ); - final deviceAuthorized = await machineCommunicator.wakeDevice( - widget.machineId, - ); + + final deviceAuthorized = + await machineCommunicator.wakeDevice(widget.machineId); + Navigator.of(context, rootNavigator: true).pop(); + + if (!deviceAuthorized) { + statusDialog( + context, + title: "Machine Error", + message: "Machine did not respond. Your balance has not been charged. Please contact support.", + isSuccess: false, + ); + return; + } + + await profileService.updateBalanceById(userId!, updatedBalance); + + setState(() { + _userBalance = updatedBalance; + _paymentCompleted = true; + }); + + await transactionService.recordTransaction( + amount: _price!, + description: + "Loyalty payment on ${MachineFormatter.formatMachineType(_machineName.toString())}", + type: "laundry", + ); + if (deviceAuthorized) { - makeNotification(_machineName.toString()); - setState(() { - _paymentCompleted = true; - }); + await makeNotification(_machineName.toString()); + setState(() => _paymentCompleted = true); statusDialog( context, title: "Machine Ready!", message: "Machine $_machineName is now active.", isSuccess: true, ); - - await transactionService.recordTransaction( - amount: _price!, - description: - "Loyalty payment on ${MachineFormatter.formatMachineType(_machineName.toString())}", - type: "laundry", - ); } else { statusDialog( context, title: "Machine Error", - message: "payment succeeded but machine did not wake up.", + message: + "Payment succeeded but machine did not wake up. Please contact support", isSuccess: false, ); } @@ -366,4 +410,4 @@ class _PaymentPageState extends State { isSuccess: true, ); } -} +} \ No newline at end of file diff --git a/lib/pages/refund_page.dart b/lib/pages/refund_page.dart index b29cb0aa..b6cf94bf 100644 --- a/lib/pages/refund_page.dart +++ b/lib/pages/refund_page.dart @@ -2,6 +2,7 @@ import 'package:clean_stream_laundry_app/widgets/status_dialog_box.dart'; import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; import 'package:clean_stream_laundry_app/logic/services/edge_function_service.dart'; import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; +import 'package:clean_stream_laundry_app/widgets/transactions_search_sheet.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:clean_stream_laundry_app/logic/services/transaction_service.dart'; @@ -30,6 +31,8 @@ class RefundPageState extends State { final authService = GetIt.instance(); bool _attemptedSubmit = false; final FocusNode _focusNode = FocusNode(); + bool _isLoading = false; + bool _isFetchingTransactions = true; @override void initState() { @@ -49,21 +52,22 @@ class RefundPageState extends State { Future _fetchTransactions() async { try { - final transactions = await transactionService.getTransactionsForUser(); + final result = await transactionService + .getRefundableTransactionsForUser(); setState(() { - recentTransactions = TransactionParser.formatTransactionsList( - transactions.take(100), - "refundHistory", - ); - recentTransactions.removeWhere((e) => e.isEmpty); - recentTransactionIDs = TransactionParser.createTransactionIDList( - transactions.take(100), - ); - recentTransactionIDs.removeWhere((e) => e.isNegative); + recentTransactions = result.transactions; + recentTransactionIDs = result.ids; }); } catch (e) { print(e.toString()); + } finally { + if (mounted) setState(() => _isFetchingTransactions = false); } + + //Filters out loyalty transactions + recentTransactions.removeWhere( + (transaction) => transaction.contains("added to Loyalty Card"), + ); } String getTransactionID() { @@ -82,175 +86,305 @@ class RefundPageState extends State { @override Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Scaffold( appBar: AppBar( leading: IconButton( onPressed: () => context.pop(), - icon: Icon( - Icons.arrow_back, - color: Colors.white, - ), - ), - backgroundColor: Theme.of(context).colorScheme.primary, - title: Text( - "Request Refund", - style: TextStyle(color: Colors.white), + icon: Icon(Icons.arrow_back, color: Colors.white), ), + backgroundColor: Colors.transparent, + title: Text("Request Refund", style: TextStyle(color: Colors.white)), centerTitle: true, + flexibleSpace: Container( + decoration: BoxDecoration( + gradient: Theme.of(context).colorScheme.primaryGradient, + ), + ), ), - body: Scaffold( - body: KeyboardListener( - focusNode: _focusNode, - autofocus: kIsWeb, - onKeyEvent: (keyEvent) { - if (keyEvent is KeyDownEvent && - keyEvent.logicalKey == LogicalKeyboardKey.enter) { - _handleRefund(); - } - }, - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox(height: 24), - DropdownButtonFormField( - initialValue: selectedTransactionIndex, - hint: Text('Select a Transaction'), - style: TextStyle( - color: Theme.of(context).colorScheme.fontInverted, + body: KeyboardListener( + focusNode: _focusNode, + autofocus: kIsWeb, + onKeyEvent: (keyEvent) { + if (keyEvent is KeyDownEvent && + keyEvent.logicalKey == LogicalKeyboardKey.enter) { + _handleRefund(); + } + }, + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header card + Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: colorScheme.primary.withOpacity(0.08), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: colorScheme.primary.withOpacity(0.2), ), - decoration: InputDecoration( - hintStyle: TextStyle(color: Colors.white), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide( - color: Theme.of(context).colorScheme.fontInverted, - width: 2, + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.primary.withOpacity(0.15), + borderRadius: BorderRadius.circular(14), ), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide( - color: Theme.of(context).colorScheme.fontSecondary, - width: 2, + child: Icon( + Icons.receipt_long_rounded, + color: colorScheme.primary, + size: 28, ), ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: Colors.blue, width: 2), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - ), - isExpanded: true, - menuMaxHeight: 250, - items: List.generate(recentTransactions.length, (index) { - return DropdownMenuItem( - value: index, - child: Text( - recentTransactions[index], - style: TextStyle( - color: Theme.of(context).colorScheme.fontInverted, - ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Submit a Refund Request", + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + color: colorScheme.fontInverted, + ), + ), + const SizedBox(height: 4), + Text( + "Select a transaction and describe your issue. Our team will review it shortly.", + style: TextStyle( + fontSize: 13, + color: colorScheme.fontSecondary, + ), + ), + ], ), - ); - }), + ), + ], + ), + ), - onChanged: (int? newIndex) { - setState(() { - selectedTransactionIndex = newIndex; - selectedTransaction = newIndex != null - ? recentTransactions[newIndex] - : null; - }); - }, + const SizedBox(height: 28), + + // Form card + Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildLabel("Select a Transaction"), + const SizedBox(height: 8), + _isFetchingTransactions + ? const Center(child: CircularProgressIndicator()) + : GestureDetector( + onTap: () async { + final selected = + await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => TransactionSearchSheet( + transactions: recentTransactions, + ), + ); - const SizedBox(height: 20), + if (selected != null) { + setState(() { + selectedTransaction = selected; + selectedTransactionIndex = + recentTransactions.indexOf(selected); + }); + } + }, + child: AbsorbPointer( + child: TextFormField( + decoration: _inputDecoration(context) + .copyWith( + hintText: 'Select a transaction', + hintStyle: TextStyle( + color: colorScheme.fontSecondary, + ), + ), + controller: TextEditingController( + text: selectedTransaction, + ), + style: TextStyle( + color: colorScheme.fontInverted, + ), + ), + ), + ), - TextField( - controller: descriptionController, - minLines: 3, - maxLines: null, - keyboardType: TextInputType.multiline, - style: TextStyle( - color: Theme.of(context).colorScheme.fontInverted, - ), - decoration: InputDecoration( - hintText: 'Please explain your reason for the refund...', - hintStyle: TextStyle( - color: Theme.of(context).colorScheme.fontSecondary, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide( - color: Theme.of(context).colorScheme.fontInverted, - width: 2, - ), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide( - color: Theme.of(context).colorScheme.fontSecondary, - width: 2, + const SizedBox(height: 24), + + _buildLabel("Reason for Refund"), + const SizedBox(height: 8), + TextField( + controller: descriptionController, + minLines: 4, + maxLines: null, + keyboardType: TextInputType.multiline, + style: TextStyle(color: colorScheme.fontInverted), + decoration: _inputDecoration(context).copyWith( + hintText: + 'Describe the issue with your transaction...', + hintStyle: TextStyle( + color: colorScheme.fontSecondary, + ), + ), ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: Colors.blue, width: 2), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), + + if (_attemptedSubmit && !isFormValid()) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Row( + children: const [ + Icon( + Icons.info_outline, + color: Colors.red, + size: 16, + ), + SizedBox(width: 6), + Text( + 'Please fill in all fields', + style: TextStyle( + color: Colors.red, + fontSize: 13, + ), + ), + ], + ), + ), + ], ), ), + ), - const SizedBox(height: 20), - - Center( - child: ElevatedButton( - onPressed: () { - setState(() { - _attemptedSubmit = true; - }); + const SizedBox(height: 24), - if (!isFormValid()) return; - - _handleRefund(); - }, - style: ElevatedButton.styleFrom( - backgroundColor: isFormValid() - ? Colors.blue - : Colors.grey, - foregroundColor: Colors.white, + SizedBox( + width: double.infinity, + height: 52, + child: ElevatedButton( + onPressed: _isLoading + ? null + : () { + setState(() => _attemptedSubmit = true); + if (!isFormValid()) return; + _handleRefund(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: isFormValid() + ? colorScheme.primary + : Colors.grey, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), ), - child: const Text("Submit Refund"), + elevation: isFormValid() ? 2 : 0, ), + child: _isLoading + ? const SizedBox( + height: 22, + width: 22, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2.5, + ), + ) + : const Text( + "Submit Refund Request", + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), ), - if (_attemptedSubmit && !isFormValid()) - const Padding( - padding: EdgeInsets.only(top: 8), - child: Center( + ), + SizedBox(height: 12,), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: const Color(0xFFFFFDE7).withOpacity(0.8), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: const Color(0xFFF9A825).withOpacity(0.4), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.info_outline_rounded, + size: 16, + color: Colors.black, + ), + const SizedBox(width: 10), + Expanded( child: Text( - 'Please fill in all fields', - textAlign: TextAlign.center, - style: TextStyle(color: Colors.red, fontSize: 14), + "Refund requests are reviewed within 3–5 business days. " + "Approved refunds will be returned to your loyalty card balance. " + "We reserve the right to deny requests that do not meet our refund policy criteria.", + style: TextStyle( + fontSize: 12, + color: Colors.black, + height: 1.5, + ), ), ), - ), - ], - ), + ], + ), + ), + SizedBox(height: 4,) + ], ), ), ), ); } + Widget _buildLabel(String text) { + return Text( + text, + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + color: Theme.of(context).colorScheme.fontInverted, + ), + ); + } + + InputDecoration _inputDecoration(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return InputDecoration( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: colorScheme.fontSecondary, width: 1.5), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: colorScheme.fontSecondary, width: 1.5), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: Colors.blue, width: 2), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + ); + } + void _handleRefund() async { + setState(() => _isLoading = true); final transactionId = getTransactionID(); final description = descriptionController.text; final userId = authService.getCurrentUserId; @@ -279,6 +413,7 @@ class RefundPageState extends State { ); _showRefundDialog(); + if (mounted) setState(() => _isLoading = false); } void _showRefundDialog() { @@ -287,7 +422,8 @@ class RefundPageState extends State { title: "Success", message: 'Your refund request has been submitted', isSuccess: true, - ); - context.go("/settings"); + ).then((_) { + context.go("/settings"); + }); } } diff --git a/lib/pages/reset_protected_page.dart b/lib/pages/reset_protected_page.dart index 25485b99..8c947974 100644 --- a/lib/pages/reset_protected_page.dart +++ b/lib/pages/reset_protected_page.dart @@ -1,307 +1,316 @@ import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; -import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; + import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; -import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; -import 'package:get_it/get_it.dart'; +import 'package:clean_stream_laundry_app/logic/parsing/password_parser.dart'; + +import '../Logic/Theme/theme.dart'; class ResetProtectedPage extends StatefulWidget { - final Uri? incomingUri; - const ResetProtectedPage({this.incomingUri, super.key}); + const ResetProtectedPage({super.key}); @override State createState() => _ResetProtectedPageState(); } class _ResetProtectedPageState extends State { + + final _passwordCtrl = TextEditingController(); + final _confirmCtrl = TextEditingController(); + final authService = GetIt.instance(); - String? code; - bool loading = true; - bool valid = false; - String? lastReceivedUri; - Map? lastParams; - final _pwController = TextEditingController(); - final _formKey = GlobalKey(); + bool _obscurePassword = true; + bool _obscureConfirm = true; + bool _isLoading = false; + + var passwordText = "New Password"; + var confirmText = "Confirm Password"; + var iconColor; + var labelColor; @override - void initState() { - super.initState(); - _initFromUri(widget.incomingUri); + void didChangeDependencies() { + super.didChangeDependencies(); + + iconColor = Theme.of(context).colorScheme.primary; + labelColor = Theme.of(context).colorScheme.primary; + } - Future _initFromUri(Uri? uri) async { + void _changeColorsToRed(String reason) { setState(() { - loading = true; - valid = false; + passwordText = reason; + confirmText = reason; + iconColor = Colors.red; + labelColor = Colors.red; }); + } - Uri effective = uri ?? Uri.base; - - // Accept app links and in-app routes for reset-protected - final isResetUri = - (effective.scheme == 'clean-stream' && - (effective.host == 'reset-protected' || - effective.path.contains('reset-protected'))) || - effective.path == '/reset-protected' || - effective.path.contains('reset-protected'); - - if (!isResetUri) { - setState(() { - loading = false; - valid = false; - }); - return; - } + void _resetColors() { + setState(() { + passwordText = "New Password"; + confirmText = "Confirm Password"; + iconColor = Colors.blue; + labelColor = Colors.blue; + }); + } - // Merge query parameters and fragment parameters (Supabase may use fragment) - final Map queryParams = effective.queryParameters; - final Map fragmentParams = effective.fragment.isNotEmpty - ? Uri.splitQueryString(effective.fragment) - : {}; + void _showMessage(String msg) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(msg)), + ); + } - final params = {...queryParams, ...fragmentParams}; + Future _submit() async { + final password = _passwordCtrl.text.trim(); + final confirm = _confirmCtrl.text.trim(); - // Common code param names: code, oobCode - code = params['code'] ?? params['oobCode']; + if (password.isEmpty || confirm.isEmpty) { + _showMessage("Please fill in all fields"); + return; + } - if (code == null) { - setState(() { - loading = false; - valid = false; + if (password != confirm) { + _changeColorsToRed("Passwords don't match"); + return; + } - // store raw values for on-screen debugging - lastReceivedUri = effective.toString(); - lastParams = params; - }); + final requirementError = PasswordParser.process(password); + if (requirementError != null) { + _changeColorsToRed(requirementError); return; } + setState(() => _isLoading = true); + try { - final response = await authService.exchangeCodeForSession(code!); - if (response == AuthenticationResponses.success) { - setState(() { - valid = true; - loading = false; - }); - } else { - setState(() { - valid = false; - loading = false; - }); - } + await authService.updatePassword(password); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Password reset successful')), + ); + + context.go("/login"); } catch (e) { - setState(() { - valid = false; - loading = false; - }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Failed to reset password')), + ); + } finally { + if (!mounted) return; + setState(() => _isLoading = false); } } @override void dispose() { - _pwController.dispose(); + _passwordCtrl.dispose(); + _confirmCtrl.dispose(); super.dispose(); } - Future _submit() async { - if (!_formKey.currentState!.validate() || code == null) return; - setState(() => loading = true); + InputDecoration _inputDecoration({ + required String label, + required IconData icon, + required bool obscure, + required VoidCallback toggle, + }) { + return InputDecoration( + labelText: label, + labelStyle: TextStyle(color: labelColor), - try { - final response = await authService.updatePassword( - _pwController.text.trim(), - ); - setState(() => loading = false); - if (response == AuthenticationResponses.success) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Password reset successful')), - ); - if (mounted) { - context.go('/login'); - } - } - } else { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Failed to reset password')), - ); - } - } - } catch (e) { - setState(() => loading = false); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Failed to reset password')), - ); - } - } - } + contentPadding: const EdgeInsets.symmetric( + vertical: 10, + horizontal: 16, + ), + + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), - String? _validatePw(String? v) { - if (v == null || v.isEmpty) return 'Please enter a password'; - if (v.length < 8) return 'Password must be at least 8 characters'; - return null; + focusedBorder: OutlineInputBorder( + borderSide: const BorderSide(color: Colors.blue, width: 2.0), + borderRadius: BorderRadius.circular(12), + ), + + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.fontSecondary, + ), + borderRadius: BorderRadius.circular(12), + ), + + prefixIcon: Icon(icon, color: iconColor), + + suffixIcon: IconButton( + icon: Icon( + obscure ? Icons.visibility_off : Icons.visibility, + color: Colors.blue, + ), + onPressed: toggle, + ), + ); } @override Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - if (loading) { - return Scaffold( - backgroundColor: scheme.surface, - body: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const CircularProgressIndicator(), - const SizedBox(height: 12), - if (lastReceivedUri != null) - Text( - 'Received: $lastReceivedUri', - style: TextStyle(color: scheme.fontSecondary), - ), - ], - ), - ), - ); - } + final theme = Theme.of(context); - if (!valid) { - return Scaffold( - backgroundColor: scheme.surface, - body: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.lock_reset, size: 80, color: scheme.primary), - const SizedBox(height: 16), - Text( - 'Invalid or expired reset link', - style: TextStyle(color: scheme.fontInverted), - ), - const SizedBox(height: 8), - if (lastReceivedUri != null) - Text( - 'Received: $lastReceivedUri', - style: TextStyle(color: scheme.fontSecondary), - ), - if (lastParams != null) ...[ - const SizedBox(height: 8), - Text('Params:', style: TextStyle(color: scheme.fontSecondary)), - for (final e in lastParams!.entries) - Text( - '${e.key}: ${e.value}', - style: TextStyle(color: scheme.fontSecondary), + return Scaffold( + backgroundColor: theme.colorScheme.surface, + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 20), + + //Clean Stream Logo + Image.asset( + "assets/Logo.png", + height: 250, + width: 250, + key: const Key('app_logo'), ), - ], - const SizedBox(height: 16), - TextButton( - onPressed: () => context.go('/login'), - child: Text( - 'Back to Login', - style: TextStyle(color: scheme.primary), - ), - ), - ], - ), - ), - ); - } - return Scaffold( - backgroundColor: scheme.surface, - appBar: AppBar( - backgroundColor: scheme.surface, - foregroundColor: scheme.fontInverted, - title: const Text('Reset Password'), - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () => context.pop(), - ), - ), - body: Padding( - padding: const EdgeInsets.all(24.0), - child: Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const SizedBox(height: 32), - Icon(Icons.lock_reset, size: 80, color: scheme.primary), - const SizedBox(height: 32), - Text( - 'Set a new password', - style: - Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, - color: scheme.fontInverted, - ) ?? - TextStyle( - fontSize: 24, + // Title + Text( + "Reset Password", + style: theme.textTheme.headlineMedium?.copyWith( fontWeight: FontWeight.bold, - color: scheme.fontInverted, ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 16), - Text( - 'Enter a new password for your account.', - style: - Theme.of(context).textTheme.bodyMedium?.copyWith( - color: scheme.fontSecondary, - ) ?? - TextStyle(color: scheme.fontSecondary), - textAlign: TextAlign.center, - ), - const SizedBox(height: 40), - TextFormField( - controller: _pwController, - obscureText: true, - style: TextStyle(color: scheme.fontInverted), - decoration: InputDecoration( - labelText: 'New password', - labelStyle: TextStyle(color: scheme.primary), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), + textAlign: TextAlign.center, ), - focusedBorder: OutlineInputBorder( - borderSide: BorderSide(color: scheme.primary, width: 2.0), - borderRadius: BorderRadius.circular(12), + + const SizedBox(height: 8), + + Text( + "Enter your new password below", + style: theme.textTheme.bodyMedium, + textAlign: TextAlign.center, ), - enabledBorder: OutlineInputBorder( - borderSide: BorderSide(color: scheme.fontSecondary), - borderRadius: BorderRadius.circular(12), + + const SizedBox(height: 30), + + // Password requirements (same style as signup) + ValueListenableBuilder( + valueListenable: _passwordCtrl, + builder: (context, value, _) { + final requirement = PasswordParser.process(value.text); + + if (requirement == null) { + return const SizedBox.shrink(); + } + + return Container( + padding: const EdgeInsets.symmetric( + vertical: 8, + horizontal: 10, + ), + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.grey.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey), + ), + child: Text( + requirement, + textAlign: TextAlign.center, + style: const TextStyle( + fontWeight: FontWeight.w600, + color: Colors.grey, + ), + ), + ); + }, ), - filled: true, - fillColor: scheme.surface, - prefixIcon: Icon(Icons.lock, color: scheme.primary), - ), - validator: _validatePw, - ), - const SizedBox(height: 24), - ElevatedButton( - onPressed: loading ? null : _submit, - style: ElevatedButton.styleFrom( - backgroundColor: scheme.primary, - foregroundColor: scheme.onPrimary, - ), - child: loading - ? const CircularProgressIndicator() - : const Text('Set Password'), - ), - TextButton( - onPressed: loading ? null : () => context.go('/login'), - child: Text( - 'Back to Login', - style: TextStyle(color: scheme.primary), - ), + + // Password field + TextField( + controller: _passwordCtrl, + obscureText: _obscurePassword, + decoration: _inputDecoration( + label: passwordText, + icon: Icons.lock, + obscure: _obscurePassword, + toggle: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + ), + onChanged: (_) { + if (iconColor == Colors.red) _resetColors(); + }, + ), + + const SizedBox(height: 16), + + // Confirm field + TextField( + controller: _confirmCtrl, + obscureText: _obscureConfirm, + decoration: _inputDecoration( + label: confirmText, + icon: Icons.lock, + obscure: _obscureConfirm, + toggle: () { + setState(() { + _obscureConfirm = !_obscureConfirm; + }); + }, + ), + onChanged: (_) { + if (_passwordCtrl.text != _confirmCtrl.text) { + _changeColorsToRed("Passwords don't match"); + } else { + _resetColors(); + } + }, + ), + + const SizedBox(height: 24), + + // Button + SizedBox( + height: 50, + child: ElevatedButton( + onPressed: _isLoading ? null : _submit, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: _isLoading + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Text( + "Reset Password", + style: TextStyle(fontSize: 16), + ), + ), + ), + + const SizedBox(height: 20), + ], ), - ], + ), ), ), ), ); } -} +} \ No newline at end of file diff --git a/lib/pages/start_machine_page.dart b/lib/pages/start_machine_page.dart index fa0129f0..c70b5c24 100644 --- a/lib/pages/start_machine_page.dart +++ b/lib/pages/start_machine_page.dart @@ -1,11 +1,53 @@ -import 'package:clean_stream_laundry_app/widgets/large_button.dart'; +import 'package:clean_stream_laundry_app/widgets/qr_button.dart'; import 'package:flutter/material.dart'; import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; import 'package:clean_stream_laundry_app/widgets/base_page.dart'; +import 'package:clean_stream_laundry_app/widgets/section_banner.dart'; +import 'package:clean_stream_laundry_app/services/kisi/door_unlocker.dart'; +import 'package:clean_stream_laundry_app/widgets/show_searching.dart'; +import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:clean_stream_laundry_app/widgets/show_searching.dart'; +import '../widgets/status_dialog_box.dart'; -class StartPage extends StatelessWidget { - const StartPage({super.key}); +const double minimumBalance = 20; + +class StartPage extends StatefulWidget { + final DoorUnlocker doorUnlocker; + + StartPage({super.key, DoorUnlocker? doorUnlocker}) + : doorUnlocker = doorUnlocker ?? DoorUnlocker(); + + @override + State createState() => _StartPageState(); +} + +class _StartPageState extends State { + final profileService = GetIt.instance(); + final authService = GetIt.instance(); + + Map? balance; + + @override + void initState() { + super.initState(); + _loadUserData(); + } + + Future _loadUserData() async { + final userId = authService.getCurrentUserId; + if (userId == null) return; + + final fetchedBalance = await profileService.getUserBalanceById(userId); + + if (mounted) { + setState(() { + balance = fetchedBalance; + }); + } + } @override Widget build(BuildContext context) { @@ -17,13 +59,16 @@ class StartPage extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + const SectionHeader(title: "Payment Options"), + Container( height: 160, - margin: const EdgeInsets.symmetric(horizontal: 23, vertical: 10), + margin: const EdgeInsets.symmetric( + horizontal: 23, vertical: 10), padding: const EdgeInsets.all(30), decoration: BoxDecoration( border: Border.all(color: Colors.blue, width: 3), - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(14), color: Colors.transparent, ), child: Row( @@ -37,7 +82,10 @@ class StartPage extends StatelessWidget { Text( "Tap To Pay", style: TextStyle( - color: Theme.of(context).colorScheme.fontInverted, + color: Theme + .of(context) + .colorScheme + .fontInverted, fontSize: 28, fontWeight: FontWeight.bold, ), @@ -45,7 +93,10 @@ class StartPage extends StatelessWidget { Text( "Tap phone to machine to pay", style: TextStyle( - color: Theme.of(context).colorScheme.fontSecondary, + color: Theme + .of(context) + .colorScheme + .fontSecondary, fontSize: 16, ), ), @@ -60,11 +111,11 @@ class StartPage extends StatelessWidget { ), ), - const SizedBox(height: 30), + const SizedBox(height: 10), SizedBox( height: 160, - child: LargeButton( + child: QRButton( headLineText: "Scan QR code", descriptionText: "Scan QR code on the machine", icon: Icons.qr_code_scanner, @@ -73,6 +124,28 @@ class StartPage extends StatelessWidget { }, ), ), + + const SizedBox(height: 10), + const SectionHeader(title: "After Hours"), + + SizedBox( + height: 160, + child: QRButton( + headLineText: "Unlock Door", + descriptionText: "Unlock doors after hours", + icon: Icons.lock_open_rounded, + onPressed: () async { + final bal = balance?["balance"]; + + if (bal == null || bal < minimumBalance) { + _showLowBalanceDialog(context); + return; + } + + await _processUnlocking(context); + }, + ), + ), ], ), ), @@ -80,4 +153,59 @@ class StartPage extends StatelessWidget { ), ); } + + Future _processUnlocking(BuildContext context) async { + cancelSearch = false; + + showSearchingDialog( + context, + () => widget.doorUnlocker.cancelUnlockingDoor(), + ); + + final success = await widget.doorUnlocker.unlockNearestDoor(); + + if (!context.mounted) return; + + if (cancelSearch) return; + + if (Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } + + statusDialog( + context, + title: success ? "Door Unlocked!" : "No Nearby Doors Found", + message: success + ? "The nearest door has been unlocked successfully" + : "We couldn't detect any nearby doors", + isSuccess: success, + ); + } +} + +void _showLowBalanceDialog(BuildContext context) { + showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + title: const Text('Low Balance'), + content: Text( + 'You need at least ${minimumBalance.toStringAsFixed(2)} to unlock a door', + ), + icon: const Icon(Icons.error), + actions: [ + TextButton( + onPressed: () { + Navigator.of(dialogContext).pop(); + context.go("/startPage"); + }, + child: const Text('OK'), + ), + ], + ); + }, + ); } \ No newline at end of file diff --git a/lib/pages/verify_code_page.dart b/lib/pages/verify_code_page.dart new file mode 100644 index 00000000..97fdf0f0 --- /dev/null +++ b/lib/pages/verify_code_page.dart @@ -0,0 +1,237 @@ +import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; + +import '../logic/services/auth_service.dart'; +import '../logic/theme/theme.dart'; + +class CodeVerificationPage extends StatefulWidget { + final String email; + + const CodeVerificationPage({Key? key, required this.email}) : super(key: key); + + @override + State createState() => _CodeVerificationPageState(); +} + +class _CodeVerificationPageState extends State { + final TextEditingController _codeController = TextEditingController(); + final authService = GetIt.instance(); + + bool _isLoading = false; + String? _error; + + void _showMessage(String msg) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(msg)), + ); + } + + Future _sendResetEmail() async { + + try { + final response = await authService.resetPassword( + widget.email, + ); + + setState(() { + _isLoading = false; + }); + + if (response == AuthenticationResponses.success) { + _showMessage('Password reset email sent! Check your email.'); + } else { + _showMessage('Failed to send reset email.'); + } + } catch (e) { + setState(() { + _isLoading = false; + }); + _showMessage('Error: $e'); + } + } + + void _verifyCode() async { + final code = _codeController.text.trim(); + + if (code.length != 6) { + setState(() { + _error = 'Please enter the 6-digit code'; + }); + return; + } + + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final response = await authService.verifyCode( + email: widget.email, + code: code, + ); + + if (!mounted) return; + + if (response == AuthenticationResponses.success) { + context.go('/reset-protected'); + return; + } else { + setState(() { + _error = 'Invalid or expired code'; + }); + } + } catch (_) { + if (!mounted) return; + + setState(() { + _error = 'Something went wrong. Try again'; + }); + } finally { + if (!mounted) return; + + setState(() { + _isLoading = false; + }); + } + } + + @override + void dispose() { + _codeController.dispose(); + super.dispose(); + } + + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + + return Scaffold( + backgroundColor: scheme.surface, + appBar: AppBar( + backgroundColor: scheme.surface, + foregroundColor: scheme.fontInverted, + title: const Text('Verify Code'), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + children: [ + const SizedBox(height: 40), + + // Title + Text( + 'Enter Verification Code', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: scheme.fontInverted, + ), + ), + + const SizedBox(height: 12), + + // Subtitle + Text( + 'We sent a 6-digit code to', + style: TextStyle(color: scheme.fontInverted.withOpacity(0.7)), + textAlign: TextAlign.center, + ), + + const SizedBox(height: 6), + + // Email + Text( + widget.email, + style: TextStyle( + fontWeight: FontWeight.w600, + color: scheme.primary, + ), + textAlign: TextAlign.center, + ), + + const SizedBox(height: 32), + + // Code Input + TextField( + controller: _codeController, + keyboardType: TextInputType.number, + maxLength: 6, + style: TextStyle( + color: scheme.fontInverted, + letterSpacing: 8, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + decoration: InputDecoration( + labelText: _error ?? '6-digit code', + labelStyle: TextStyle( + color: _error != null ? Colors.red : scheme.primary, + ), + counterText: '', + prefixIcon: Icon( + Icons.lock, + color: _error != null ? Colors.red : scheme.primary, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + onChanged: (_) { + if (_error != null) { + setState(() => _error = null); + } + }, + ), + + const SizedBox(height: 32), + + // Verify Button + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _isLoading ? null : _verifyCode, + style: ElevatedButton.styleFrom( + backgroundColor: scheme.primary, + foregroundColor: scheme.onPrimary, + padding: const EdgeInsets.symmetric(vertical: 14), + ), + child: _isLoading + ? const SizedBox( + height: 22, + width: 22, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Verify'), + ), + ), + + const SizedBox(height: 16), + + // Error Message (optional extra under field) + if (_error != null) + Text( + _error!, + style: const TextStyle(color: Colors.red), + ), + + const SizedBox(height: 16), + + // Resend + TextButton( + onPressed: _sendResetEmail, + child: Text( + 'Resend code', + style: TextStyle(color: scheme.primary), + ), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/services/kisi/door_unlocker.dart b/lib/services/kisi/door_unlocker.dart new file mode 100644 index 00000000..755ecfdf --- /dev/null +++ b/lib/services/kisi/door_unlocker.dart @@ -0,0 +1,52 @@ +import '../../logic/services/door_unlock_service.dart'; + +class DoorUnlocker implements DoorUnlockService { + bool cancelled = false; + + final _readerToDoor = { + "Reader A": "Front Door", + //"Reader A": "Broken Door", + "Reader B": "Back Door", + }; + + @override + Future> getNearbyDoors() async { + await Future.delayed(const Duration(seconds: 1)); + + if (cancelled) return []; + + return _readerToDoor.values.toList(); + } + + Future unlockDoor(String doorId) async { + await Future.delayed(const Duration(seconds: 1)); + + if (cancelled) return false; + + return doorId != "Broken Door"; // simulate access denied + } + + void cancelUnlockingDoor() { + cancelled = true; + } + + Future unlockNearestDoor() async { + cancelled = false; + + final doors = await getNearbyDoors(); + + if (cancelled || doors.isEmpty) { + return false; + } + + final nearest = doors.first; + + final success = await unlockDoor(nearest); + + if (cancelled) { + return false; + } + + return success; + } +} \ No newline at end of file diff --git a/lib/services/stripe/stripe_service_mobile.dart b/lib/services/stripe/stripe_service_mobile.dart index 92476d95..4f4302d4 100644 --- a/lib/services/stripe/stripe_service_mobile.dart +++ b/lib/services/stripe/stripe_service_mobile.dart @@ -1,6 +1,7 @@ import 'package:clean_stream_laundry_app/logic/services/payment_service.dart'; import 'package:clean_stream_laundry_app/logic/services/edge_function_service.dart'; import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_stripe/flutter_stripe.dart'; import 'package:get_it/get_it.dart'; @@ -9,6 +10,7 @@ class StripeService implements PaymentService { final _stripeInstance = GetIt.instance(); + @override Future makePayment(double amount) async { try { String? paymentIntentClientSecret = await createPaymentIntent( @@ -22,6 +24,29 @@ class StripeService implements PaymentService { paymentSheetParameters: SetupPaymentSheetParameters( paymentIntentClientSecret: paymentIntentClientSecret, merchantDisplayName: "Clean Stream Laundry Solutions", + appearance: PaymentSheetAppearance( + colors: PaymentSheetAppearanceColors( + primary: Color(0xFF2073A9), + background: CupertinoColors.systemBackground, + componentBackground: CupertinoColors.secondarySystemBackground, + componentBorder: CupertinoColors.separator, + componentText: CupertinoColors.label, + placeholderText: CupertinoColors.separator + ), + shapes: const PaymentSheetShape(borderRadius: 20), + primaryButton: PaymentSheetPrimaryButtonAppearance( + colors: PaymentSheetPrimaryButtonTheme( + light: PaymentSheetPrimaryButtonThemeColors( + background: Color(0xFF2073A9), + text: CupertinoColors.white, + ), + ), + shapes: const PaymentSheetPrimaryButtonShape(blurRadius: 20), + ), + ), + // Commented out for testing until we get a merchant id from Apple Developer + //applePay: const PaymentSheetApplePay(merchantCountryCode: 'US'), + googlePay: const PaymentSheetGooglePay(merchantCountryCode: 'US'), ), ); await _stripeInstance.presentPaymentSheet(); @@ -53,8 +78,7 @@ class StripeService implements PaymentService { } @protected - String convertDollarsToCents(double amount) { - final calculatedAmount = (amount * 100).toInt(); - return calculatedAmount.toString(); + int convertDollarsToCents(double amount) { + return (amount * 100).toInt(); } } diff --git a/lib/services/supabase/supabase_auth_service.dart b/lib/services/supabase/supabase_auth_service.dart index 4272138e..c813fa82 100644 --- a/lib/services/supabase/supabase_auth_service.dart +++ b/lib/services/supabase/supabase_auth_service.dart @@ -23,16 +23,13 @@ class SupabaseAuthService implements AuthService { @override Future isLoggedIn() async { - AuthenticationResponses output = AuthenticationResponses.failure; - try { - await _client.auth.refreshSession(); - if (_client.auth.currentUser != null) { - output = AuthenticationResponses.success; - } - } catch (e) { - print(e); - return output; + AuthenticationResponses output = AuthenticationResponses.success; + final session = _client.auth.currentSession; + + if (!(session != null && session.user != null)) { + output = AuthenticationResponses.failure; } + return output; } @@ -228,7 +225,7 @@ class SupabaseAuthService implements AuthService { } @override - Future handleOAuthRedirect(Uri uri) async { + Future getSessionFromURI(Uri uri) async { await _client.auth.getSessionFromUrl(uri); } @@ -270,8 +267,7 @@ class SupabaseAuthService implements AuthService { try { // Send password reset email and redirect back to the app via deep link. await _client.auth.resetPasswordForEmail( - email, - redirectTo: 'clean-stream://reset-protected', + email ); output = AuthenticationResponses.success; } catch (e) { @@ -308,4 +304,26 @@ class SupabaseAuthService implements AuthService { } return output; } + + @override + Future verifyCode({required String email, required String code}) async { + AuthenticationResponses output = AuthenticationResponses.success; + + try { + final response = await _client.auth.verifyOTP( + email: email, + token: code, + type: OtpType.recovery, + ); + + if (response.session == null) { + output = AuthenticationResponses.failure; + } + }catch (e){ + output = AuthenticationResponses.failure; + } + + return output; + } + } diff --git a/lib/services/supabase/supabase_profile_service.dart b/lib/services/supabase/supabase_profile_service.dart index 34919419..f310e226 100644 --- a/lib/services/supabase/supabase_profile_service.dart +++ b/lib/services/supabase/supabase_profile_service.dart @@ -36,7 +36,7 @@ class SupabaseProfileService extends ProfileService { try { final response = await _client .from('profiles') - .select("full_name, balance") + .select("full_name, balance, reward_tracker") .eq('id', userId) .single(); return response; @@ -66,16 +66,25 @@ class SupabaseProfileService extends ProfileService { } @override - Future updateBalanceById(double balance) async { - final userId = _client.auth.currentUser?.id; - - if (userId == null) { + Future updateBalanceById(String userId, double balance) async { + try { + await _client + .from("profiles") + .update({"balance": balance}) + .eq("id", userId); + } on PostgrestException { + return; + } catch (e) { return; } + } + + @override + Future updateRewardsById(String userId, double amount) async { try { await _client .from("profiles") - .update({"balance": balance}) + .update({"reward_tracker": amount}) .eq("id", userId); } on PostgrestException { return; diff --git a/lib/services/supabase/supabase_transaction_service.dart b/lib/services/supabase/supabase_transaction_service.dart index 87669753..f8e50ef5 100644 --- a/lib/services/supabase/supabase_transaction_service.dart +++ b/lib/services/supabase/supabase_transaction_service.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'package:clean_stream_laundry_app/logic/parsing/transaction_parser.dart'; import 'package:clean_stream_laundry_app/logic/services/transaction_service.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; @@ -17,7 +18,7 @@ class SupabaseTransactionService extends TransactionService{ final response = await _client .from('transactions') - .select('id, amount, description, created_at') + .select('id, amount, description, created_at, type') .eq('user_id', user.id) .order('created_at', ascending: false); @@ -25,18 +26,25 @@ class SupabaseTransactionService extends TransactionService{ } @override - Future>> getRefundableTransactionsForUser() async { + Future<({List transactions, List ids})> getRefundableTransactionsForUser() async { final user = _client.auth.currentUser; - if (user == null) return []; final response = await _client .from('transactions') .select('id, amount, description, created_at') - .eq('user_id', user.id) + .eq('user_id', user!.id) .neq('requested_refund', true) .order('created_at', ascending: false); - return List>.from(response); + final raw = List>.from(response).take(100); + + final transactions = TransactionParser.formatTransactionsList(raw, "refundHistory") + ..removeWhere((e) => e.isEmpty || e.contains("added to Loyalty Card")); + + final ids = TransactionParser.createTransactionIDList(raw) + ..removeWhere((e) => e.isNegative); + + return (transactions: transactions, ids: ids); } @override diff --git a/lib/widgets/credit_card.dart b/lib/widgets/credit_card.dart index e5b2803c..7e13e5be 100644 --- a/lib/widgets/credit_card.dart +++ b/lib/widgets/credit_card.dart @@ -19,7 +19,7 @@ class CreditCard extends StatelessWidget { color: Theme.of(context).colorScheme.cardPrimary, elevation: 10, margin: const EdgeInsets.symmetric(horizontal: 24), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), child: Padding( padding: EdgeInsets.only(top: 10, bottom: 10), child: SizedBox( diff --git a/lib/widgets/custom_app_bar.dart b/lib/widgets/custom_app_bar.dart index dce7b52f..6d757082 100644 --- a/lib/widgets/custom_app_bar.dart +++ b/lib/widgets/custom_app_bar.dart @@ -1,3 +1,4 @@ +import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; @@ -7,35 +8,43 @@ class CustomAppBar extends StatelessWidget implements PreferredSizeWidget { @override Widget build(BuildContext context) { return AppBar( - backgroundColor: Theme.of(context).colorScheme.primary, + toolbarHeight: 40, + backgroundColor: Colors.transparent, + elevation: 0, + centerTitle: false, titleSpacing: 0, - title: Padding( - padding: const EdgeInsets.only(left: 12), - child: GestureDetector( - onTap: () => context.go("/homePage"), - child: SizedBox( - width: 160, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 5), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Image.asset("assets/Icon.png", height: 26), - const SizedBox(width: 2), - Image.asset("assets/Slogan.png", height: 22), - ], - ), + flexibleSpace: Container( + decoration: BoxDecoration( + gradient: Theme.of(context).colorScheme.primaryGradient, + ), + ), + title: Padding( + padding: const EdgeInsets.only(left: 12), + child: GestureDetector( + onTap: () => context.go("/homePage"), + child: SizedBox( + width: 160, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 5), + decoration: BoxDecoration( + gradient: Theme.of(context).colorScheme.backgroundGradient, + borderRadius: BorderRadius.circular(14), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset("assets/Icon.png", height: 26), + const SizedBox(width: 2), + Image.asset("assets/Slogan.png", height: 22), + ], ), ), ), ), + ), ); } @override Size get preferredSize => const Size.fromHeight(kToolbarHeight); -} \ No newline at end of file +} diff --git a/lib/widgets/dryer_controls_card.dart b/lib/widgets/dryer_controls_card.dart new file mode 100644 index 00000000..4b8bd3f7 --- /dev/null +++ b/lib/widgets/dryer_controls_card.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; +import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; + +class DryerControlsCard extends StatefulWidget { + final void Function(double price, int minutes) onChanged; + + const DryerControlsCard({super.key, required this.onChanged}); + + @override + State createState() => _DryerControlsCardState(); +} + +class _DryerControlsCardState extends State { + int _selectedMinutes = 30; + + double get _calculatedPrice => (_selectedMinutes / 5) * 0.25; + + @override + void initState() { + super.initState(); + // Notify parent of the initial default values after first frame + WidgetsBinding.instance.addPostFrameCallback((_) { + widget.onChanged(_calculatedPrice, _selectedMinutes); + }); + } + + @override + Widget build(BuildContext context) { + return Card( + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + color: Theme.of(context).colorScheme.greyCard, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20), + child: Column( + children: [ + Text( + 'Set Dry Time', + style: TextStyle( + fontSize: 26, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + + Text( + '$_selectedMinutes min', + style: const TextStyle( + fontSize: 48, + fontWeight: FontWeight.bold, + color: Color(0xFF2073A9), + ), + ), + + const SizedBox(height: 4), + + Text( + '\$0.25 per 5 minutes', + style: TextStyle(fontSize: 13, color: Colors.black), + ), + + const SizedBox(height: 8), + + SliderTheme( + data: SliderTheme.of(context).copyWith( + activeTrackColor: const Color(0xFF2073A9), + inactiveTrackColor: const Color(0xFF2073A9).withOpacity(0.2), + thumbColor: const Color(0xFF2073A9), + overlayColor: const Color(0xFF2073A9).withOpacity(0.12), + trackHeight: 4, + ), + child: Slider( + value: _selectedMinutes.toDouble(), + min: 5, + max: 90, + divisions: 17, + onChanged: (value) { + final snapped = (value / 5).round() * 5; + setState(() => _selectedMinutes = snapped); + widget.onChanged(_calculatedPrice, snapped); + }, + ), + ), + + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '5 min', + style: TextStyle(fontSize: 12, color: Colors.black), + ), + Text( + '90 min', + style: TextStyle(fontSize: 12, color: Colors.black), + ), + ], + ), + ), + ], + ), + ) + ); + } +} diff --git a/lib/widgets/navigation_bar.dart b/lib/widgets/navigation_bar.dart index 7e4d672b..477936d5 100644 --- a/lib/widgets/navigation_bar.dart +++ b/lib/widgets/navigation_bar.dart @@ -2,16 +2,42 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; class NavBar extends StatelessWidget { + static const List<_NavDestination> _destinations = [ + _NavDestination( + route: '/homePage', + label: 'Home', + icon: Icons.home, + routePrefixes: ['/homePage'], + ), + _NavDestination( + route: '/startPage', + label: 'Start', + icon: Icons.local_laundry_service_sharp, + routePrefixes: ['/start', '/startPage', '/scanner', '/paymentPage'], + ), + _NavDestination( + route: '/loyalty', + label: 'Wallet', + icon: Icons.wallet, + routePrefixes: ['/loyalty'], + ), + _NavDestination( + route: '/settings', + label: 'Settings', + icon: Icons.settings, + routePrefixes: ['/settings', '/monthlyTransactionHistory', '/refundPage'], + ), + ]; - const NavBar({super.key,}); + const NavBar({super.key}); int _getIndex(String location) { - if (location.startsWith('/homePage')) return 0; - if (location.startsWith('/start')) return 1; - if (location.startsWith('/loyalty')) return 2; - if (location.startsWith('/settings')) return 3; - if (location.startsWith('/monthlyTransactionHistory')) return 3; - if (location.startsWith('/refundPage')) return 3; + for (var i = 0; i < _destinations.length; i++) { + final destination = _destinations[i]; + if (destination.routePrefixes.any(location.startsWith)) { + return i; + } + } return 0; } @@ -20,35 +46,45 @@ class NavBar extends StatelessWidget { final router = GoRouter.of(context); final location = router.routeInformationProvider.value.uri.toString(); final currentIndex = _getIndex(location); + final colorScheme = Theme.of(context).colorScheme; - return BottomNavigationBar( - currentIndex: currentIndex, - backgroundColor: Theme.of(context).colorScheme.surface, - selectedItemColor: Theme.of(context).colorScheme.primary, - unselectedItemColor: Colors.grey, - type: BottomNavigationBarType.fixed, - onTap: (index) { - switch (index) { - case 0: - context.go("/homePage"); - break; - case 1: - context.go("/startPage"); - break; - case 2: - context.go("/loyalty"); - break; - case 3: - context.go("/settings"); - break; - } - }, - items: const [ - BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'), - BottomNavigationBarItem(icon: Icon(Icons.local_laundry_service_sharp), label: 'Start'), - BottomNavigationBarItem(icon: Icon(Icons.wallet), label: 'Wallet'), - BottomNavigationBarItem(icon: Icon(Icons.settings), label: 'Settings'), - ], + return SizedBox( + height: 82, + child: BottomNavigationBar( + currentIndex: currentIndex, + backgroundColor: colorScheme.surface, + selectedItemColor: colorScheme.primary, + unselectedItemColor: Colors.grey, + type: BottomNavigationBarType.fixed, + onTap: (index) { + final route = _destinations[index].route; + if (!location.startsWith(route)) { + context.go(route); + } + }, + items: _destinations + .map( + (destination) => BottomNavigationBarItem( + icon: Icon(destination.icon), + label: destination.label, + ), + ) + .toList(), + ), ); } -} \ No newline at end of file +} + +class _NavDestination { + final String route; + final String label; + final IconData icon; + final List routePrefixes; + + const _NavDestination({ + required this.route, + required this.label, + required this.icon, + required this.routePrefixes, + }); +} diff --git a/lib/widgets/large_button.dart b/lib/widgets/qr_button.dart similarity index 53% rename from lib/widgets/large_button.dart rename to lib/widgets/qr_button.dart index 86cbe8e4..bddbc92b 100644 --- a/lib/widgets/large_button.dart +++ b/lib/widgets/qr_button.dart @@ -1,12 +1,12 @@ import 'package:flutter/material.dart'; -class LargeButton extends StatelessWidget { +class QRButton extends StatelessWidget { final String headLineText; final String descriptionText; final IconData icon; final VoidCallback? onPressed; - const LargeButton({ + const QRButton({ super.key, required this.headLineText, required this.descriptionText, @@ -16,22 +16,25 @@ class LargeButton extends StatelessWidget { @override Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Center( child: SizedBox( width: double.infinity, - height: 170, + height: 160, // slightly tighter child: Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: ElevatedButton( onPressed: onPressed, style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 20), + backgroundColor: colors.primary, + foregroundColor: Colors.white, + elevation: 8, // less aggressive + shadowColor: colors.primary.withOpacity(0.4), + padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 20), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(25), + borderRadius: BorderRadius.circular(14), ), - elevation: 8, - backgroundColor: Colors.blue.shade800, - shadowColor: Colors.blueAccent.shade400, ), child: Row( crossAxisAlignment: CrossAxisAlignment.center, @@ -43,27 +46,33 @@ class LargeButton extends StatelessWidget { children: [ Text( headLineText, - style: const TextStyle( - fontSize: 28, - fontWeight: FontWeight.bold, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, color: Colors.white, ), ), - const SizedBox(height: 8), + const SizedBox(height: 6), Text( descriptionText, - style: const TextStyle( - fontSize: 16, - color: Colors.white70, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Colors.white.withOpacity(0.85), ), ), ], ), ), - Icon( - icon, - size: 48, - color: Colors.white, + const SizedBox(width: 12), + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.15), + borderRadius: BorderRadius.circular(14), + ), + child: Icon( + icon, + size: 28, // reduced from 48 (too large before) + color: Colors.white, + ), ), ], ), @@ -72,4 +81,4 @@ class LargeButton extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/section_banner.dart b/lib/widgets/section_banner.dart new file mode 100644 index 00000000..3d6698ea --- /dev/null +++ b/lib/widgets/section_banner.dart @@ -0,0 +1,43 @@ +import 'package:flutter/cupertino.dart' show StatelessWidget, BuildContext, Widget, EdgeInsets, Expanded, FontWeight, TextStyle, Text, Padding, Row; +import 'package:flutter/material.dart'; + +class SectionHeader extends StatelessWidget { + final String title; + const SectionHeader({super.key, required this.title}); + + @override + Widget build(BuildContext context) { + final color = Theme.of(context).colorScheme.primary; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: Row( + children: [ + Expanded( + child: Divider( + thickness: 2, + color: color, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Text( + title, + style: TextStyle( + color: color, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + Expanded( + child: Divider( + thickness: 2, + color: color, + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/settings_card.dart b/lib/widgets/settings_card.dart index 5da68567..9cf98da2 100644 --- a/lib/widgets/settings_card.dart +++ b/lib/widgets/settings_card.dart @@ -21,6 +21,8 @@ class SettingsCard extends StatelessWidget { Widget build(BuildContext context) { return Card( margin: const EdgeInsets.symmetric(horizontal: 24), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + elevation: 6, child: ListTile( leading: Icon( icon, diff --git a/lib/widgets/show_searching.dart b/lib/widgets/show_searching.dart new file mode 100644 index 00000000..070b231f --- /dev/null +++ b/lib/widgets/show_searching.dart @@ -0,0 +1,68 @@ +import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; +import 'package:flutter/material.dart'; + +late bool cancelSearch = false; + +void showSearchingDialog( + BuildContext context, + VoidCallback onCancel, + ) { + cancelSearch = false; + + showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 20), + Text( + "Finding Nearby Doors...", + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Theme.of(dialogContext).colorScheme.fontInverted, + ), + ), + const SizedBox(height: 10), + Text( + "Please wait while we search for the nearest door.", + textAlign: TextAlign.center, + style: TextStyle( + color: Theme.of(dialogContext).colorScheme.fontInverted, + ), + ), + const SizedBox(height: 20), + TextButton( + onPressed: () { + cancelSearch = true; + onCancel(); + Navigator.of(dialogContext).pop(); + }, + style: TextButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + child: const Text( + "Cancel", + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ), + ], + ), + ), + ), + ); +} \ No newline at end of file diff --git a/lib/widgets/status_dialog_box.dart b/lib/widgets/status_dialog_box.dart index 26b39125..b5e779f3 100644 --- a/lib/widgets/status_dialog_box.dart +++ b/lib/widgets/status_dialog_box.dart @@ -1,13 +1,13 @@ import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; import 'package:flutter/material.dart'; -void statusDialog( +Future statusDialog( BuildContext context, { required String title, required String message, required bool isSuccess, }) { - showDialog( + return showDialog( context: context, builder: (dialogContext) => AlertDialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), diff --git a/lib/widgets/transactions_search_sheet.dart b/lib/widgets/transactions_search_sheet.dart new file mode 100644 index 00000000..ac59376c --- /dev/null +++ b/lib/widgets/transactions_search_sheet.dart @@ -0,0 +1,86 @@ +import 'package:clean_stream_laundry_app/Logic/Theme/theme.dart'; +import 'package:flutter/material.dart'; + +class TransactionSearchSheet extends StatefulWidget { + final List transactions; + + const TransactionSearchSheet({ + super.key, + required this.transactions, + }); + + @override + State createState() => + _TransactionSearchSheetState(); +} + +class _TransactionSearchSheetState + extends State { + late List filtered; + String query = ''; + + @override + void initState() { + super.initState(); + filtered = widget.transactions; + } + + void _filter(String value) { + setState(() { + query = value; + filtered = widget.transactions + .where((transaction) => + transaction.toLowerCase().contains(value.toLowerCase())) + .toList(); + }); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: SafeArea( + child: SizedBox( + height: 500, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: TextField( + autofocus: true, + decoration: InputDecoration( + hintText: 'Search...', + hintStyle: TextStyle(color: Theme.of(context).colorScheme.fontSecondary), + prefixIcon: Icon( + Icons.search, + color: Theme.of(context).colorScheme.fontSecondary, + ), + ), + onChanged: _filter, + ), + ), + Expanded( + child: ListView.builder( + itemCount: filtered.length, + itemBuilder: (_, index) { + final transaction = filtered[index]; + + return ListTile( + textColor: Theme.of(context).colorScheme.fontInverted, + title: Text(transaction), + onTap: () { + Navigator.pop(context, transaction); + }, + ); + }, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/washer_controls_card.dart b/lib/widgets/washer_controls_card.dart new file mode 100644 index 00000000..775d4103 --- /dev/null +++ b/lib/widgets/washer_controls_card.dart @@ -0,0 +1,145 @@ +import 'package:flutter/material.dart'; +import 'package:clean_stream_laundry_app/logic/theme/theme.dart'; + +class WasherControlsCard extends StatefulWidget { + final void Function(double addedCost) onCycleChanged; + + WasherControlsCard({ + super.key, + required this.onCycleChanged, + }); + + + @override + State createState() => _WasherControlsCardState(); +} + +class _WasherControlsCardState extends State { + String? selectedCycle; + + @override + Widget build(BuildContext context) { + return Card( + elevation: 4, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + color: Theme.of(context).colorScheme.greyCard, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20), + child: Column( + children: [ + Text( + 'Select Your Cycle', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + const SizedBox(height: 1), + Text( + 'Please make sure the selected cycle is the cycle on your machine', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 16, + color: Colors.black87, + ), + ), + const SizedBox(height: 10), + + Column( + children: [ + Row( + children: [ + Expanded( + child: _WasherButton( + label: "Hot Heavy", + selected: selectedCycle == "Hot Heavy", + onTap: () { + setState(() => selectedCycle = "Hot Heavy"); + widget.onCycleChanged(0.5); + }, + ), + ), + const SizedBox(width: 14), + Expanded( + child: _WasherButton( + label: "Hot Normal", + selected: selectedCycle == "Hot Normal", + onTap: () { + setState(() => selectedCycle = "Hot Normal"); + widget.onCycleChanged(0.25); + }, + ), + ), + ], + ), + const SizedBox(height: 14), + Row( + children: [ + Expanded( + child: _WasherButton( + label: "Cold Heavy", + selected: selectedCycle == "Cold Heavy", + onTap: () { + setState(() => selectedCycle = "Cold Heavy"); + widget.onCycleChanged(0.25); + }, + ), + ), + const SizedBox(width: 14), + Expanded( + child: _WasherButton( + label: "Cold Normal", + selected: selectedCycle == "Cold Normal", + onTap: () { + setState(() => selectedCycle = "Cold Normal"); + widget.onCycleChanged(0); + }, + ), + ), + ], + ), + ], + ), + ], + ), + ), + ); + } +} + +class _WasherButton extends StatelessWidget { + final String label; + final bool selected; + final VoidCallback onTap; + + const _WasherButton({ + required this.label, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return ElevatedButton( + onPressed: onTap, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 18), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + backgroundColor: selected + ? Colors.green + : Theme.of(context).colorScheme.primary, + foregroundColor: Colors.white, + ), + child: Text( + label, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ); + } +} \ No newline at end of file diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index d7f56dcf..e996361c 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,7 +7,9 @@ import Foundation import app_links import flutter_local_notifications +import geolocator_apple import mobile_scanner +import package_info_plus import path_provider_foundation import shared_preferences_foundation import url_launcher_macos @@ -15,7 +17,9 @@ import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) + GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..fbd3a17d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,344 @@ +{ + "name": "cleanstreamlaundryapp", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cleanstreamlaundryapp", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "supabase": "^2.76.14" + }, + "devDependencies": { + "@types/node": "^25.3.0" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@types/node": { + "version": "25.3.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz", + "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/bin-links": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bin-links/-/bin-links-6.0.0.tgz", + "integrity": "sha512-X4CiKlcV2GjnCMwnKAfbVWpHa++65th9TuzAEYtZoATiOE2DQKhSp4CJlyLoTqdhBKlXjpXjCTYPNNFS33Fi6w==", + "license": "ISC", + "dependencies": { + "cmd-shim": "^8.0.0", + "npm-normalize-package-bin": "^5.0.0", + "proc-log": "^6.0.0", + "read-cmd-shim": "^6.0.0", + "write-file-atomic": "^7.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/cmd-shim": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/cmd-shim/-/cmd-shim-8.0.0.tgz", + "integrity": "sha512-Jk/BK6NCapZ58BKUxlSI+ouKRbjH1NLZCgJkYoab+vEHUY3f6OzpNBN9u7HFSv9J6TRDGs4PLOHezoKGaFRSCA==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "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/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "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/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/npm-normalize-package-bin": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-5.0.0.tgz", + "integrity": "sha512-CJi3OS4JLsNMmr2u07OJlhcrPxCeOeP/4xq67aWNai6TNWWbTrlNDgl8NcFKVlcBKp18GPj+EzbNIgrBfZhsag==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/read-cmd-shim": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/read-cmd-shim/-/read-cmd-shim-6.0.0.tgz", + "integrity": "sha512-1zM5HuOfagXCBWMN83fuFI/x+T/UhZ7k+KIzhrHXcQoeX5+7gmaDYjELQHmmzIodumBHeByBJT4QYS7ufAgs7A==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/supabase": { + "version": "2.76.14", + "resolved": "https://registry.npmjs.org/supabase/-/supabase-2.76.14.tgz", + "integrity": "sha512-2XmYs8+A4WXd+w/OND9u9qbSTnGdLCuddnii01H1LkmgwcZ9krXwxElE+YYmzhsEKCUHv5wVjAf5HTUwQ4PnVA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bin-links": "^6.0.0", + "https-proxy-agent": "^7.0.2", + "node-fetch": "^3.3.2", + "tar": "7.5.9" + }, + "bin": { + "supabase": "bin/supabase" + }, + "engines": { + "npm": ">=8" + } + }, + "node_modules/tar": { + "version": "7.5.9", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.9.tgz", + "integrity": "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/write-file-atomic": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-7.0.0.tgz", + "integrity": "sha512-YnlPC6JqnZl6aO4uRc+dx5PHguiR9S6WeoLtpxNT9wIG+BDya7ZNE1q7KOjVgaA73hKhKLpVPgJ5QA9THQ5BRg==", + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..fd423542 --- /dev/null +++ b/package.json @@ -0,0 +1,31 @@ +{ + "name": "cleanstreamlaundryapp", + "version": "1.0.0", + "description": "Code repository for the Clean Stream Laundry Client mobile/web app.", + "homepage": "https://github.com/jamaki604/CleanStreamLaundryApp#readme", + "bugs": { + "url": "https://github.com/jamaki604/CleanStreamLaundryApp/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/jamaki604/CleanStreamLaundryApp.git" + }, + "license": "ISC", + "author": "", + "type": "module", + "main": "index.js", + "directories": { + "lib": "lib", + "test": "test" + }, + "scripts": { + "supabase:update-functions": "node scripts/update-functions.cjs", + "test:coverage": "deno test --reload --coverage=coverage_deno supabase/functions" + }, + "dependencies": { + "supabase": "^2.76.14" + }, + "devDependencies": { + "@types/node": "^25.3.0" + } +} diff --git a/pubspec.lock b/pubspec.lock index 31cdf559..18dea148 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,18 +5,18 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f + sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d" url: "https://pub.dev" source: hosted - version: "85.0.0" + version: "93.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: "974859dc0ff5f37bc4313244b3218c791810d03ab3470a579580279ba971a48d" + sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b url: "https://pub.dev" source: hosted - version: "7.7.1" + version: "10.0.1" ansicolor: dependency: transitive description: @@ -69,10 +69,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -352,6 +352,70 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.4" + geoclue: + dependency: transitive + description: + name: geoclue + sha256: c2a998c77474fc57aa00c6baa2928e58f4b267649057a1c76738656e9dbd2a7f + url: "https://pub.dev" + source: hosted + version: "0.1.1" + geolocator: + dependency: "direct main" + description: + name: geolocator + sha256: "79939537046c9025be47ec645f35c8090ecadb6fe98eba146a0d25e8c1357516" + url: "https://pub.dev" + source: hosted + version: "14.0.2" + geolocator_android: + dependency: transitive + description: + name: geolocator_android + sha256: "179c3cb66dfa674fc9ccbf2be872a02658724d1c067634e2c427cf6df7df901a" + url: "https://pub.dev" + source: hosted + version: "5.0.2" + geolocator_apple: + dependency: transitive + description: + name: geolocator_apple + sha256: dbdd8789d5aaf14cf69f74d4925ad1336b4433a6efdf2fce91e8955dc921bf22 + url: "https://pub.dev" + source: hosted + version: "2.3.13" + geolocator_linux: + dependency: transitive + description: + name: geolocator_linux + sha256: d64112a205931926f4363bb6bd48f14cb38e7326833041d170615586cd143797 + url: "https://pub.dev" + source: hosted + version: "0.2.4" + geolocator_platform_interface: + dependency: transitive + description: + name: geolocator_platform_interface + sha256: "30cb64f0b9adcc0fb36f628b4ebf4f731a2961a0ebd849f4b56200205056fe67" + url: "https://pub.dev" + source: hosted + version: "4.2.6" + geolocator_web: + dependency: transitive + description: + name: geolocator_web + sha256: b1ae9bdfd90f861fde8fd4f209c37b953d65e92823cb73c7dee1fa021b06f172 + url: "https://pub.dev" + source: hosted + version: "4.1.3" + geolocator_windows: + dependency: transitive + description: + name: geolocator_windows + sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6" + url: "https://pub.dev" + source: hosted + version: "0.2.5" get_it: dependency: "direct main" description: @@ -384,6 +448,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.15.0" + gsettings: + dependency: transitive + description: + name: gsettings + sha256: "1b0ce661f5436d2db1e51f3c4295a49849f03d304003a7ba177d01e3a858249c" + url: "https://pub.dev" + source: hosted + version: "0.2.8" gtk: dependency: transitive description: @@ -448,14 +520,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" - js: - dependency: transitive - description: - name: js - sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" - url: "https://pub.dev" - source: hosted - version: "0.7.2" json_annotation: dependency: transitive description: @@ -540,18 +604,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.18" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: @@ -560,6 +624,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.17.0" + mgrs_dart: + dependency: transitive + description: + name: mgrs_dart + sha256: fb89ae62f05fa0bb90f70c31fc870bcbcfd516c843fb554452ab3396f78586f7 + url: "https://pub.dev" + source: hosted + version: "2.0.0" mime: dependency: transitive description: @@ -608,6 +680,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + package_info_plus: + dependency: transitive + description: + name: package_info_plus + sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d + url: "https://pub.dev" + source: hosted + version: "9.0.0" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" path: dependency: transitive description: @@ -1237,6 +1325,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" wkt_parser: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index d187fe77..3f60d83e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -53,7 +53,7 @@ dependencies: get_it: 9.0.2 flutter_map: ^8.2.2 latlong2: ^0.9.1 - + geolocator: ^14.0.2 dev_dependencies: flutter_test: diff --git a/scripts/update-functions.cjs b/scripts/update-functions.cjs new file mode 100644 index 00000000..831afdaf --- /dev/null +++ b/scripts/update-functions.cjs @@ -0,0 +1,29 @@ +const { execSync } = require("child_process"); + +try { + console.log("Fetching function list..."); + + const output = execSync( + "npx supabase functions list --output json", + { encoding: "utf-8" } + ); + + const functions = JSON.parse(output); + + if (!functions.length) { + console.log("No functions found."); + process.exit(0); + } + + for (const fn of functions) { + console.log(`Downloading ${fn.name}...`); + execSync(`npx supabase functions download ${fn.slug}`, { + stdio: "inherit", + }); + } + + console.log("All functions updated successfully."); +} catch (err) { + console.error("Error updating functions:", err.message); + process.exit(1); +} \ No newline at end of file diff --git a/supabase/.gitignore b/supabase/.gitignore new file mode 100644 index 00000000..ad9264f0 --- /dev/null +++ b/supabase/.gitignore @@ -0,0 +1,8 @@ +# Supabase +.branches +.temp + +# dotenvx +.env.keys +.env.local +.env.*.local diff --git a/supabase/config.toml b/supabase/config.toml new file mode 100644 index 00000000..06284e21 --- /dev/null +++ b/supabase/config.toml @@ -0,0 +1,388 @@ +# For detailed configuration reference documentation, visit: +# https://supabase.com/docs/guides/local-development/cli/config +# A string used to distinguish different Supabase projects on the same host. Defaults to the +# working directory name when running `supabase init`. +project_id = "CleanStreamLaundryApp" + +[api] +enabled = true +# Port to use for the API URL. +port = 54321 +# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API +# endpoints. `public` and `graphql_public` schemas are included by default. +schemas = ["public", "graphql_public"] +# Extra schemas to add to the search_path of every request. +extra_search_path = ["public", "extensions"] +# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size +# for accidental or malicious requests. +max_rows = 1000 + +[api.tls] +# Enable HTTPS endpoints locally using a self-signed certificate. +enabled = false +# Paths to self-signed certificate pair. +# cert_path = "../certs/my-cert.pem" +# key_path = "../certs/my-key.pem" + +[db] +# Port to use for the local database URL. +port = 54322 +# Port used by db diff command to initialize the shadow database. +shadow_port = 54320 +# Maximum amount of time to wait for health check when starting the local database. +health_timeout = "2m" +# The database major version to use. This has to be the same as your remote database's. Run `SHOW +# server_version;` on the remote database to check. +major_version = 17 + +[db.pooler] +enabled = false +# Port to use for the local connection pooler. +port = 54329 +# Specifies when a server connection can be reused by other clients. +# Configure one of the supported pooler modes: `transaction`, `session`. +pool_mode = "transaction" +# How many server connections to allow per user/database pair. +default_pool_size = 20 +# Maximum number of client connections allowed. +max_client_conn = 100 + +# [db.vault] +# secret_key = "env(SECRET_VALUE)" + +[db.migrations] +# If disabled, migrations will be skipped during a db push or reset. +enabled = true +# Specifies an ordered list of schema files that describe your database. +# Supports glob patterns relative to supabase directory: "./schemas/*.sql" +schema_paths = [] + +[db.seed] +# If enabled, seeds the database after migrations during a db reset. +enabled = true +# Specifies an ordered list of seed files to load during db reset. +# Supports glob patterns relative to supabase directory: "./seeds/*.sql" +sql_paths = ["./seed.sql"] + +[db.network_restrictions] +# Enable management of network restrictions. +enabled = false +# List of IPv4 CIDR blocks allowed to connect to the database. +# Defaults to allow all IPv4 connections. Set empty array to block all IPs. +allowed_cidrs = ["0.0.0.0/0"] +# List of IPv6 CIDR blocks allowed to connect to the database. +# Defaults to allow all IPv6 connections. Set empty array to block all IPs. +allowed_cidrs_v6 = ["::/0"] + +# Uncomment to reject non-secure connections to the database. +# [db.ssl_enforcement] +# enabled = true + +[realtime] +enabled = true +# Bind realtime via either IPv4 or IPv6. (default: IPv4) +# ip_version = "IPv6" +# The maximum length in bytes of HTTP request headers. (default: 4096) +# max_header_length = 4096 + +[studio] +enabled = true +# Port to use for Supabase Studio. +port = 54323 +# External URL of the API server that frontend connects to. +api_url = "http://127.0.0.1" +# OpenAI API Key to use for Supabase AI in the Supabase Studio. +openai_api_key = "env(OPENAI_API_KEY)" + +# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they +# are monitored, and you can view the emails that would have been sent from the web interface. +[inbucket] +enabled = true +# Port to use for the email testing server web interface. +port = 54324 +# Uncomment to expose additional ports for testing user applications that send emails. +# smtp_port = 54325 +# pop3_port = 54326 +# admin_email = "admin@email.com" +# sender_name = "Admin" + +[storage] +enabled = true +# The maximum file size allowed (e.g. "5MB", "500KB"). +file_size_limit = "50MiB" + +# Uncomment to configure local storage buckets +# [storage.buckets.images] +# public = false +# file_size_limit = "50MiB" +# allowed_mime_types = ["image/png", "image/jpeg"] +# objects_path = "./images" + +# Allow connections via S3 compatible clients +[storage.s3_protocol] +enabled = true + +# Image transformation API is available to Supabase Pro plan. +# [storage.image_transformation] +# enabled = true + +# Store analytical data in S3 for running ETL jobs over Iceberg Catalog +# This feature is only available on the hosted platform. +[storage.analytics] +enabled = false +max_namespaces = 5 +max_tables = 10 +max_catalogs = 2 + +# Analytics Buckets is available to Supabase Pro plan. +# [storage.analytics.buckets.my-warehouse] + +# Store vector embeddings in S3 for large and durable datasets +# This feature is only available on the hosted platform. +[storage.vector] +enabled = false +max_buckets = 10 +max_indexes = 5 + +# Vector Buckets is available to Supabase Pro plan. +# [storage.vector.buckets.documents-openai] + +[auth] +enabled = true +# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used +# in emails. +site_url = "http://127.0.0.1:3000" +# A list of *exact* URLs that auth providers are permitted to redirect to post authentication. +additional_redirect_urls = ["https://127.0.0.1:3000"] +# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). +jwt_expiry = 3600 +# JWT issuer URL. If not set, defaults to the local API URL (http://127.0.0.1:/auth/v1). +# jwt_issuer = "" +# Path to JWT signing key. DO NOT commit your signing keys file to git. +# signing_keys_path = "./signing_keys.json" +# If disabled, the refresh token will never expire. +enable_refresh_token_rotation = true +# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds. +# Requires enable_refresh_token_rotation = true. +refresh_token_reuse_interval = 10 +# Allow/disallow new user signups to your project. +enable_signup = true +# Allow/disallow anonymous sign-ins to your project. +enable_anonymous_sign_ins = false +# Allow/disallow testing manual linking of accounts +enable_manual_linking = false +# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more. +minimum_password_length = 6 +# Passwords that do not meet the following requirements will be rejected as weak. Supported values +# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols` +password_requirements = "" + +[auth.rate_limit] +# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled. +email_sent = 2 +# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled. +sms_sent = 30 +# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true. +anonymous_users = 30 +# Number of sessions that can be refreshed in a 5 minute interval per IP address. +token_refresh = 150 +# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users). +sign_in_sign_ups = 30 +# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address. +token_verifications = 30 +# Number of Web3 logins that can be made in a 5 minute interval per IP address. +web3 = 30 + +# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`. +# [auth.captcha] +# enabled = true +# provider = "hcaptcha" +# secret = "" + +[auth.email] +# Allow/disallow new user signups via email to your project. +enable_signup = true +# If enabled, a user will be required to confirm any email change on both the old, and new email +# addresses. If disabled, only the new email is required to confirm. +double_confirm_changes = true +# If enabled, users need to confirm their email address before signing in. +enable_confirmations = false +# If enabled, users will need to reauthenticate or have logged in recently to change their password. +secure_password_change = false +# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email. +max_frequency = "1s" +# Number of characters used in the email OTP. +otp_length = 6 +# Number of seconds before the email OTP expires (defaults to 1 hour). +otp_expiry = 3600 + +# Use a production-ready SMTP server +# [auth.email.smtp] +# enabled = true +# host = "smtp.sendgrid.net" +# port = 587 +# user = "apikey" +# pass = "env(SENDGRID_API_KEY)" +# admin_email = "admin@email.com" +# sender_name = "Admin" + +# Uncomment to customize email template +# [auth.email.template.invite] +# subject = "You have been invited" +# content_path = "./supabase/templates/invite.html" + +# Uncomment to customize notification email template +# [auth.email.notification.password_changed] +# enabled = true +# subject = "Your password has been changed" +# content_path = "./templates/password_changed_notification.html" + +[auth.sms] +# Allow/disallow new user signups via SMS to your project. +enable_signup = false +# If enabled, users need to confirm their phone number before signing in. +enable_confirmations = false +# Template for sending OTP to users +template = "Your code is {{ .Code }}" +# Controls the minimum amount of time that must pass before sending another sms otp. +max_frequency = "5s" + +# Use pre-defined map of phone number to OTP for testing. +# [auth.sms.test_otp] +# 4152127777 = "123456" + +# Configure logged in session timeouts. +# [auth.sessions] +# Force log out after the specified duration. +# timebox = "24h" +# Force log out if the user has been inactive longer than the specified duration. +# inactivity_timeout = "8h" + +# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object. +# [auth.hook.before_user_created] +# enabled = true +# uri = "pg-functions://postgres/auth/before-user-created-hook" + +# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used. +# [auth.hook.custom_access_token] +# enabled = true +# uri = "pg-functions:////" + +# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`. +[auth.sms.twilio] +enabled = false +account_sid = "" +message_service_sid = "" +# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead: +auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)" + +# Multi-factor-authentication is available to Supabase Pro plan. +[auth.mfa] +# Control how many MFA factors can be enrolled at once per user. +max_enrolled_factors = 10 + +# Control MFA via App Authenticator (TOTP) +[auth.mfa.totp] +enroll_enabled = false +verify_enabled = false + +# Configure MFA via Phone Messaging +[auth.mfa.phone] +enroll_enabled = false +verify_enabled = false +otp_length = 6 +template = "Your code is {{ .Code }}" +max_frequency = "5s" + +# Configure MFA via WebAuthn +# [auth.mfa.web_authn] +# enroll_enabled = true +# verify_enabled = true + +# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, +# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`, +# `twitter`, `x`, `slack`, `spotify`, `workos`, `zoom`. +[auth.external.apple] +enabled = false +client_id = "" +# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead: +secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)" +# Overrides the default auth redirectUrl. +redirect_uri = "" +# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, +# or any other third-party OIDC providers. +url = "" +# If enabled, the nonce check will be skipped. Required for local sign in with Google auth. +skip_nonce_check = false +# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address. +email_optional = false + +# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard. +# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting. +[auth.web3.solana] +enabled = false + +# Use Firebase Auth as a third-party provider alongside Supabase Auth. +[auth.third_party.firebase] +enabled = false +# project_id = "my-firebase-project" + +# Use Auth0 as a third-party provider alongside Supabase Auth. +[auth.third_party.auth0] +enabled = false +# tenant = "my-auth0-tenant" +# tenant_region = "us" + +# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth. +[auth.third_party.aws_cognito] +enabled = false +# user_pool_id = "my-user-pool-id" +# user_pool_region = "us-east-1" + +# Use Clerk as a third-party provider alongside Supabase Auth. +[auth.third_party.clerk] +enabled = false +# Obtain from https://clerk.com/setup/supabase +# domain = "example.clerk.accounts.dev" + +# OAuth server configuration +[auth.oauth_server] +# Enable OAuth server functionality +enabled = false +# Path for OAuth consent flow UI +authorization_url_path = "/oauth/consent" +# Allow dynamic client registration +allow_dynamic_registration = false + +[edge_runtime] +enabled = true +# Supported request policies: `oneshot`, `per_worker`. +# `per_worker` (default) — enables hot reload during local development. +# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks). +policy = "per_worker" +# Port to attach the Chrome inspector for debugging edge functions. +inspector_port = 8083 +# The Deno major version to use. +deno_version = 2 + +# [edge_runtime.secrets] +# secret_key = "env(SECRET_VALUE)" + +[analytics] +enabled = true +port = 54327 +# Configure one of the supported backends: `postgres`, `bigquery`. +backend = "postgres" + +# Experimental features may be deprecated any time +[experimental] +# Configures Postgres storage engine to use OrioleDB (S3) +orioledb_version = "" +# Configures S3 bucket URL, eg. .s3-.amazonaws.com +s3_host = "env(S3_HOST)" +# Configures S3 bucket region, eg. us-east-1 +s3_region = "env(S3_REGION)" +# Configures AWS_ACCESS_KEY_ID for S3 bucket +s3_access_key = "env(S3_ACCESS_KEY)" +# Configures AWS_SECRET_ACCESS_KEY for S3 bucket +s3_secret_key = "env(S3_SECRET_KEY)" diff --git a/supabase/functions/approveRefund/index.test.ts b/supabase/functions/approveRefund/index.test.ts new file mode 100644 index 00000000..7c6b677d --- /dev/null +++ b/supabase/functions/approveRefund/index.test.ts @@ -0,0 +1,137 @@ +import { + assertEquals, + assertRejects, + } from "https://deno.land/std/testing/asserts.ts"; + + import { processRefund } from "./logic.ts"; + + function createMockDeps(overrides: Partial = {}) { + return { + updateRefund: async (_: string) => {}, + getUserEmail: async (_: string) => "test@example.com", + incrementLoyalty: async (_: string, __: number) => {}, + sendEmail: async (_: string, __: string, ___: string) => {}, + ...overrides, + }; + } + + Deno.test("processRefund succeeds with valid input", async () => { + const deps = createMockDeps(); + + const result = await processRefund( + { + userId: "user1", + transactionId: "tx1", + amount: "25", + }, + deps + ); + + assertEquals(result.success, true); + assertEquals(result.transactionId, "tx1"); + assertEquals(result.amount, "25"); + }); + + Deno.test("throws if params are missing", async () => { + const deps = createMockDeps(); + + await assertRejects( + () => + processRefund( + { + userId: "", + transactionId: "tx1", + amount: "25", + }, + deps + ), + Error, + "Missing params" + ); + }); + + Deno.test("propagates updateRefund error", async () => { + const deps = createMockDeps({ + updateRefund: async () => { + throw new Error("DB failure"); + }, + }); + + await assertRejects( + () => + processRefund( + { + userId: "user1", + transactionId: "tx1", + amount: "25", + }, + deps + ), + Error, + "DB failure" + ); + }); + + Deno.test("throws if user email not found", async () => { + const deps = createMockDeps({ + getUserEmail: async () => "", + }); + + await assertRejects( + () => + processRefund( + { + userId: "user1", + transactionId: "tx1", + amount: "25", + }, + deps + ), + Error, + "User email not found" + ); + }); + + Deno.test("propagates incrementLoyalty error", async () => { + const deps = createMockDeps({ + incrementLoyalty: async () => { + throw new Error("RPC failed"); + }, + }); + + await assertRejects( + () => + processRefund( + { + userId: "user1", + transactionId: "tx1", + amount: "25", + }, + deps + ), + Error, + "RPC failed" + ); + }); + + Deno.test("propagates sendEmail error", async () => { + const deps = createMockDeps({ + sendEmail: async () => { + throw new Error("Email failed"); + }, + }); + + await assertRejects( + () => + processRefund( + { + userId: "user1", + transactionId: "tx1", + amount: "25", + }, + deps + ), + Error, + "Email failed" + ); + }); \ No newline at end of file diff --git a/supabase/functions/approveRefund/index.ts b/supabase/functions/approveRefund/index.ts new file mode 100644 index 00000000..77848ef9 --- /dev/null +++ b/supabase/functions/approveRefund/index.ts @@ -0,0 +1,129 @@ +import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; +import { processRefund } from "./logic.ts"; + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization, apikey", +}; + +serve(async (req) => { + if (req.method === "OPTIONS") { + return new Response(null, { status: 200, headers: corsHeaders }); + } + + try { + const url = new URL(req.url); + const userId = url.searchParams.get("user_id") || ""; + const transactionId = url.searchParams.get("transaction_id") || ""; + const amount = url.searchParams.get("amount") || ""; + + const supabaseUrl = Deno.env.get("SUPABASE_URL"); + const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY"); + const resendKey = Deno.env.get("RESEND_API_KEY"); + + if (!supabaseUrl || !serviceKey || !resendKey) { + return new Response("Server configuration error", { + status: 500, + headers: corsHeaders, + }); + } + + const supabase = createClient(supabaseUrl, serviceKey, { + auth: { autoRefreshToken: false, persistSession: false }, + }); + + const deps = { + updateRefund: async (transactionId: string) => { + const { error } = await supabase + .from("Refunds") + .update({ status: "approved" }) + .eq("transaction_id", transactionId); + + if (error) throw new Error(error.message); + }, + + getUserEmail: async (userId: string) => { + const { data, error } = + await supabase.auth.admin.getUserById(userId); + + if (error || !data?.user?.email) { + throw new Error("User not found"); + } + + return data.user.email; + }, + + incrementLoyalty: async (userId: string, amount: number) => { + const { error } = await supabase.rpc( + "increment_loyalty_balance", + { + user_id: userId, + increment_amount: amount, + } + ); + + if (error) throw new Error(error.message); + }, + + sendEmail: async ( + email: string, + transactionId: string, + amount: string + ) => { + const response = await fetch( + "https://api.resend.com/emails", + { + method: "POST", + headers: { + Authorization: `Bearer ${resendKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + from: "refund@updates.cleanstreamlaundry.com", + to: email, + subject: "Your Refund Was Approved", + html: ` +

Refund Approved

+

Your refund for transaction + ${transactionId} was approved.

+

$${amount} has been added to your loyalty card.

+ `, + }), + } + ); + + if (!response.ok) { + throw new Error("Failed to send email"); + } + }, + }; + + const result = await processRefund( + { userId, transactionId, amount }, + deps + ); + + return new Response( + JSON.stringify(result), + { + headers: { + "Content-Type": "application/json", + ...corsHeaders, + }, + } + ); + } catch (error) { + return new Response( + JSON.stringify({ error: error.message }), + { + status: 500, + headers: { + "Content-Type": "application/json", + ...corsHeaders, + }, + } + ); + } +}); \ No newline at end of file diff --git a/supabase/functions/approveRefund/logic.ts b/supabase/functions/approveRefund/logic.ts new file mode 100644 index 00000000..40de0543 --- /dev/null +++ b/supabase/functions/approveRefund/logic.ts @@ -0,0 +1,41 @@ +export interface RefundDependencies { + updateRefund: (transactionId: string) => Promise; + getUserEmail: (userId: string) => Promise; + incrementLoyalty: (userId: string, amount: number) => Promise; + sendEmail: (email: string, transactionId: string, amount: string) => Promise; + } + + export interface RefundParams { + userId: string; + transactionId: string; + amount: string; + } + + export async function processRefund( + params: RefundParams, + deps: RefundDependencies + ) { + const { userId, transactionId, amount } = params; + + if (!userId || !transactionId || !amount) { + throw new Error("Missing params"); + } + + await deps.updateRefund(transactionId); + + const email = await deps.getUserEmail(userId); + + if (!email) { + throw new Error("User email not found"); + } + + await deps.incrementLoyalty(userId, Number(amount)); + + await deps.sendEmail(email, transactionId, amount); + + return { + success: true, + transactionId, + amount, + }; + } \ No newline at end of file diff --git a/supabase/functions/changePassword/index.ts b/supabase/functions/changePassword/index.ts new file mode 100644 index 00000000..528989c8 --- /dev/null +++ b/supabase/functions/changePassword/index.ts @@ -0,0 +1,33 @@ +import { serve } from "https://deno.land/std@0.224.0/http/server.ts"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; +import { resetPassword } from "./logic.ts"; + +serve(async (req) => { + try { + const { code, password } = await req.json(); + + const supabase = createClient( + Deno.env.get("SUPABASE_URL")!, + Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!, + ); + + const result = await resetPassword( + { code, password }, + { + exchangeCode: async (code) => { + const { data, error } = await supabase.auth.exchangeCodeForSession(code); + if (error || !data?.user) return { userId: "" }; + return { userId: data.user.id }; + }, + updatePassword: async (userId, password) => { + const { error } = await supabase.auth.admin.updateUserById(userId, { password }); + if (error) throw error; + }, + } + ); + + return new Response("Password updated", { status: 200 }); + } catch (e) { + return new Response(e.message || "Bad Request", { status: 400 }); + } +}); \ No newline at end of file diff --git a/supabase/functions/changePassword/logic.test.ts b/supabase/functions/changePassword/logic.test.ts new file mode 100644 index 00000000..d8a443ee --- /dev/null +++ b/supabase/functions/changePassword/logic.test.ts @@ -0,0 +1,58 @@ +import { assertEquals, assertRejects } from "https://deno.land/std/testing/asserts.ts"; +import { resetPassword } from "./logic.ts"; + +function createMockDeps(overrides: Partial = {}) { + return { + exchangeCode: async (code: string) => ({ userId: "user1" }), + updatePassword: async (userId: string, password: string) => {}, + ...overrides, + }; +} + +Deno.test("resetPassword succeeds with valid input", async () => { + const deps = createMockDeps(); + + const result = await resetPassword( + { code: "abc123", password: "newpass" }, + deps + ); + + assertEquals(result.success, true); + assertEquals(result.userId, "user1"); +}); + +Deno.test("throws if code or password is missing", async () => { + const deps = createMockDeps(); + + await assertRejects( + () => resetPassword({ code: "", password: "newpass" }, deps), + Error, + "Missing code or password" + ); +}); + +Deno.test("throws if exchangeCode returns no userId", async () => { + const deps = createMockDeps({ + exchangeCode: async () => ({ userId: "" }), + }); + + await assertRejects( + () => resetPassword({ code: "abc123", password: "newpass" }, deps), + Error, + "Invalid or expired code" + ); +}); + +Deno.test("propagates updatePassword error", async () => { + const deps = createMockDeps({ + updatePassword: async () => { + throw new Error("Update failed"); + }, + }); + + await assertRejects( + () => resetPassword({ code: "abc123", password: "newpass" }, deps), + Error, + "Update failed" + ); +}); \ No newline at end of file diff --git a/supabase/functions/changePassword/logic.ts b/supabase/functions/changePassword/logic.ts new file mode 100644 index 00000000..9144fa31 --- /dev/null +++ b/supabase/functions/changePassword/logic.ts @@ -0,0 +1,30 @@ +export interface ResetPasswordDeps { + exchangeCode: (code: string) => Promise<{ userId: string }>; + updatePassword: (userId: string, password: string) => Promise; +} + +export interface ResetPasswordParams { + code: string; + password: string; +} + +export async function resetPassword( + params: ResetPasswordParams, + deps: ResetPasswordDeps +) { + const { code, password } = params; + + if (!code || !password) { + throw new Error("Missing code or password"); + } + + const { userId } = await deps.exchangeCode(code); + + if (!userId) { + throw new Error("Invalid or expired code"); + } + + await deps.updatePassword(userId, password); + + return { success: true, userId }; +} \ No newline at end of file diff --git a/supabase/functions/checkPaymentResult/index.ts b/supabase/functions/checkPaymentResult/index.ts new file mode 100644 index 00000000..80325601 --- /dev/null +++ b/supabase/functions/checkPaymentResult/index.ts @@ -0,0 +1,33 @@ +import { getPaymentStatusLogic, PaymentDeps, PaymentParams } from "./logic.ts"; +import Stripe from "npm:stripe@^14.0.0"; + +const stripeKey = Deno.env.get("STRIPE_SECRET_KEY"); +if (!stripeKey) throw new Error("Missing STRIPE_SECRET_KEY env var"); + +const stripe = new Stripe(stripeKey, { apiVersion: "2023-10-16" }); + +export async function getPaymentStatus(req: Request) { + const headers = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + "Content-Type": "application/json", + }; + + if (req.method === "OPTIONS") return new Response(null, { headers }); + + try { + const { session_id } = await req.json(); + + const deps: PaymentDeps = { + retrieveSession: (id) => stripe.checkout.sessions.retrieve(id), + }; + + const result = await getPaymentStatusLogic({ sessionId: session_id }, deps); + + return new Response(JSON.stringify({ status: result }), { headers }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + return new Response(JSON.stringify({ error: message }), { status: 400, headers }); + } +} \ No newline at end of file diff --git a/supabase/functions/checkPaymentResult/logic.test.ts b/supabase/functions/checkPaymentResult/logic.test.ts new file mode 100644 index 00000000..0e0f08af --- /dev/null +++ b/supabase/functions/checkPaymentResult/logic.test.ts @@ -0,0 +1,23 @@ +import { assertEquals, assertRejects } from "https://deno.land/std@0.224.0/testing/asserts.ts"; +import { getPaymentStatusLogic, PaymentDeps } from "./logic.ts"; + +Deno.test("returns payment status for valid session", async () => { + const fakeDeps: PaymentDeps = { + retrieveSession: async (id) => ({ payment_status: "paid" }), + }; + + const result = await getPaymentStatusLogic({ sessionId: "sess_123" }, fakeDeps); + assertEquals(result, "paid"); +}); + +Deno.test("throws if sessionId is missing", async () => { + const fakeDeps: PaymentDeps = { + retrieveSession: async (id) => ({ payment_status: "paid" }), + }; + + await assertRejects( // await + assertRejects + () => getPaymentStatusLogic({ sessionId: "" }, fakeDeps), + Error, + "Missing sessionId" + ); + }); \ No newline at end of file diff --git a/supabase/functions/checkPaymentResult/logic.ts b/supabase/functions/checkPaymentResult/logic.ts new file mode 100644 index 00000000..b6d4faf3 --- /dev/null +++ b/supabase/functions/checkPaymentResult/logic.ts @@ -0,0 +1,18 @@ +export interface PaymentDeps { + retrieveSession: (sessionId: string) => Promise<{ payment_status: string }>; +} + +export interface PaymentParams { + sessionId: string; +} + +export async function getPaymentStatusLogic( + params: PaymentParams, + deps: PaymentDeps +) { + const { sessionId } = params; + if (!sessionId) throw new Error("Missing sessionId"); + + const session = await deps.retrieveSession(sessionId); + return session.payment_status; +} \ No newline at end of file diff --git a/supabase/functions/createCheckoutSession/index.ts b/supabase/functions/createCheckoutSession/index.ts new file mode 100644 index 00000000..0b39de8f --- /dev/null +++ b/supabase/functions/createCheckoutSession/index.ts @@ -0,0 +1,44 @@ +import Stripe from "npm:stripe@^14.0.0"; +import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; +import { handleCheckout } from "./logic.ts"; + +const CORS_HEADERS = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization, apikey, x-client-info", +}; + +serve(async (req) => { + if (req.method === "OPTIONS") { + return new Response(null, { headers: CORS_HEADERS }); + } + + try { + const authHeader = req.headers.get("Authorization") || ""; + const token = authHeader.replace("Bearer ", ""); + + const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY")!, { + apiVersion: "2023-10-16", + }); + + const supabase = createClient( + Deno.env.get("SUPABASE_URL")!, + Deno.env.get("SUPABASE_ANON_KEY")!, + { global: { headers: token ? { Authorization: `Bearer ${token}` } : {} } } + ); + + const response = await handleCheckout(req, { stripe, supabase }); + + return new Response(response.body, { + status: response.status, + headers: { ...CORS_HEADERS, ...Object.fromEntries(response.headers) }, + }); + } catch (err) { + const status = err.message === "Unauthorized" ? 401 : 400; + return new Response(JSON.stringify({ error: err.message }), { + status, + headers: CORS_HEADERS, + }); + } +}); \ No newline at end of file diff --git a/supabase/functions/createCheckoutSession/logic.test.ts b/supabase/functions/createCheckoutSession/logic.test.ts new file mode 100644 index 00000000..022daaf0 --- /dev/null +++ b/supabase/functions/createCheckoutSession/logic.test.ts @@ -0,0 +1,258 @@ +import { + assertEquals, + assertRejects, + } from "https://deno.land/std@0.168.0/testing/asserts.ts"; + import { + getAuthenticatedUser, + createCheckoutSession, + handleCheckout + } from "./logic.ts"; + + function makeSupabaseMock(user: object | null, error: object | null = null) { + return { + auth: { + getUser: () => Promise.resolve({ data: { user }, error }), + }, + } as any; + } + + function makeStripeMock(overrides?: Partial<{ url: string; id: string }>) { + return { + checkout: { + sessions: { + create: (_params: unknown) => + Promise.resolve({ + url: overrides?.url ?? "https://checkout.stripe.com/pay/test_session", + id: overrides?.id ?? "cs_test_abc123", + }), + }, + }, + } as any; + } + + function makeRequest(body: unknown) { + return new Request("http://localhost/checkout", { + method: "POST", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }); + } + + Deno.test("getAuthenticatedUser — returns user when authenticated", async () => { + const mockUser = { id: "user-123", email: "test@example.com" }; + const supabase = makeSupabaseMock(mockUser); + + const user = await getAuthenticatedUser(supabase); + assertEquals(user.id, "user-123"); + }); + + Deno.test("getAuthenticatedUser — throws when user is null", async () => { + const supabase = makeSupabaseMock(null); + + await assertRejects( + () => getAuthenticatedUser(supabase), + Error, + "Unauthorized" + ); + }); + + Deno.test("getAuthenticatedUser — throws when Supabase returns an error", async () => { + const supabase = makeSupabaseMock(null, { message: "JWT expired" }); + + await assertRejects( + () => getAuthenticatedUser(supabase), + Error, + "Unauthorized" + ); + }); + + Deno.test("createCheckoutSession — returns url and session_id", async () => { + const stripe = makeStripeMock(); + + const result = await createCheckoutSession(stripe, 2500, "user-123"); + + assertEquals(result.session_id, "cs_test_abc123"); + assertEquals(result.url, "https://checkout.stripe.com/pay/test_session"); + }); + + Deno.test("createCheckoutSession — passes correct amount to Stripe", async () => { + let capturedParams: any; + const stripe = { + checkout: { + sessions: { + create: (params: unknown) => { + capturedParams = params; + return Promise.resolve({ url: "https://stripe.com", id: "cs_1" }); + }, + }, + }, + } as any; + + await createCheckoutSession(stripe, 4999, "user-456"); + + assertEquals(capturedParams.line_items[0].price_data.unit_amount, 4999); + assertEquals(capturedParams.metadata.user_id, "user-456"); + }); + + Deno.test("createCheckoutSession — throws on invalid amount (zero)", async () => { + const stripe = makeStripeMock(); + + await assertRejects( + () => createCheckoutSession(stripe, 0, "user-123"), + Error, + "Invalid amount" + ); + }); + + Deno.test("createCheckoutSession — throws on negative amount", async () => { + const stripe = makeStripeMock(); + + await assertRejects( + () => createCheckoutSession(stripe, -100, "user-123"), + Error, + "Invalid amount" + ); + }); + + Deno.test("createCheckoutSession — uses custom baseUrl for redirect URLs", async () => { + let capturedParams: any; + const stripe = { + checkout: { + sessions: { + create: (params: unknown) => { + capturedParams = params; + return Promise.resolve({ url: "https://stripe.com", id: "cs_1" }); + }, + }, + }, + } as any; + + await createCheckoutSession(stripe, 1000, "user-123", "https://myapp.com"); + + assertEquals(capturedParams.success_url, "https://myapp.com/homePage"); + assertEquals(capturedParams.cancel_url, "https://myapp.com/homePage"); + }); + + Deno.test("handleCheckout — returns 200 with url and session_id on success", async () => { + const req = makeRequest({ amount: 2500 }); + + const res = await handleCheckout(req, { + stripe: makeStripeMock(), + supabase: makeSupabaseMock({ id: "user-123" }), + }); + const body = await res.json(); + + assertEquals(res.status, 200); + assertEquals(body.session_id, "cs_test_abc123"); + assertEquals(body.url, "https://checkout.stripe.com/pay/test_session"); + }); + + Deno.test("handleCheckout — response Content-Type is application/json", async () => { + const req = makeRequest({ amount: 2500 }); + + const res = await handleCheckout(req, { + stripe: makeStripeMock(), + supabase: makeSupabaseMock({ id: "user-123" }), + }); + + assertEquals(res.headers.get("Content-Type"), "application/json"); + }); + + Deno.test("handleCheckout — throws Unauthorized when user is null", async () => { + const req = makeRequest({ amount: 2500 }); + + await assertRejects( + () => handleCheckout(req, { + stripe: makeStripeMock(), + supabase: makeSupabaseMock(null), + }), + Error, + "Unauthorized" + ); + }); + + Deno.test("handleCheckout — throws Unauthorized when Supabase returns auth error", async () => { + const req = makeRequest({ amount: 2500 }); + + await assertRejects( + () => handleCheckout(req, { + stripe: makeStripeMock(), + supabase: makeSupabaseMock(null, { message: "JWT expired" }), + }), + Error, + "Unauthorized" + ); + }); + + Deno.test("handleCheckout — throws Invalid amount when amount is zero", async () => { + const req = makeRequest({ amount: 0 }); + + await assertRejects( + () => handleCheckout(req, { + stripe: makeStripeMock(), + supabase: makeSupabaseMock({ id: "user-123" }), + }), + Error, + "Invalid amount" + ); + }); + + Deno.test("handleCheckout — throws Invalid amount when amount is missing", async () => { + const req = makeRequest({}); + + await assertRejects( + () => handleCheckout(req, { + stripe: makeStripeMock(), + supabase: makeSupabaseMock({ id: "user-123" }), + }), + Error, + "Invalid amount" + ); + }); + + Deno.test("handleCheckout — forwards user id from auth to Stripe session metadata", async () => { + let capturedParams: any; + const stripe = { + checkout: { + sessions: { + create: (params: unknown) => { + capturedParams = params; + return Promise.resolve({ url: "https://stripe.com", id: "cs_1" }); + }, + }, + }, + } as any; + + const req = makeRequest({ amount: 1500 }); + await handleCheckout(req, { + stripe, + supabase: makeSupabaseMock({ id: "user-abc" }), + }); + + assertEquals(capturedParams.metadata.user_id, "user-abc"); + }); + + Deno.test("handleCheckout — uses provided baseUrl for redirect URLs", async () => { + let capturedParams: any; + const stripe = { + checkout: { + sessions: { + create: (params: unknown) => { + capturedParams = params; + return Promise.resolve({ url: "https://stripe.com", id: "cs_1" }); + }, + }, + }, + } as any; + + const req = makeRequest({ amount: 1500 }); + await handleCheckout( + req, + { stripe, supabase: makeSupabaseMock({ id: "user-123" }) }, + "https://myapp.com" + ); + + assertEquals(capturedParams.success_url, "https://myapp.com/homePage"); + assertEquals(capturedParams.cancel_url, "https://myapp.com/homePage"); + }); + \ No newline at end of file diff --git a/supabase/functions/createCheckoutSession/logic.ts b/supabase/functions/createCheckoutSession/logic.ts new file mode 100644 index 00000000..6fb58fb3 --- /dev/null +++ b/supabase/functions/createCheckoutSession/logic.ts @@ -0,0 +1,72 @@ +import Stripe from "npm:stripe@^14.0.0"; +import { SupabaseClient } from "https://esm.sh/@supabase/supabase-js@2"; + +export interface CheckoutDeps { + stripe: Stripe; + supabase: SupabaseClient; +} + +export interface CheckoutResult { + url: string | null; + session_id: string; +} + +export async function getAuthenticatedUser(supabase: SupabaseClient) { + const { + data: { user }, + error, + } = await supabase.auth.getUser(); + + if (error || !user) { + throw new Error("Unauthorized"); + } + + return user; +} + +export async function createCheckoutSession( + stripe: Stripe, + amount: number, + userId: string, + baseUrl = "http://localhost:8080" +): Promise { + if (!amount || typeof amount !== "number" || amount <= 0) { + throw new Error("Invalid amount"); + } + + const session = await stripe.checkout.sessions.create({ + mode: "payment", + payment_method_types: ["card"], + line_items: [ + { + price_data: { + currency: "usd", + product_data: { name: "Laundry Service" }, + unit_amount: amount, + }, + quantity: 1, + }, + ], + metadata: { user_id: userId }, + success_url: `${baseUrl}/homePage`, + cancel_url: `${baseUrl}/homePage`, + }); + + return { url: session.url, session_id: session.id }; +} + + +export async function handleCheckout( + req: Request, + deps: CheckoutDeps, + baseUrl?: string +): Promise { + const { amount } = await req.json(); + + const user = await getAuthenticatedUser(deps.supabase); + const result = await createCheckoutSession(deps.stripe, amount, user.id, baseUrl); + + return new Response(JSON.stringify(result), { + headers: { "Content-Type": "application/json" }, + }); +} \ No newline at end of file diff --git a/supabase/functions/delete-account/index.ts b/supabase/functions/delete-account/index.ts new file mode 100644 index 00000000..f3c5efdf --- /dev/null +++ b/supabase/functions/delete-account/index.ts @@ -0,0 +1,35 @@ +import { serve } from "https://deno.land/std@0.182.0/http/server.ts"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.14.0"; +import { handleDeleteUser } from "./logic.ts"; + +const CORS_HEADERS = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization, apikey, x-client-info", +}; + +serve(async (req) => { + if (req.method === "OPTIONS") { + return new Response(null, { headers: CORS_HEADERS }); + } + + try { + const supabaseAdmin = createClient( + Deno.env.get("SUPABASE_URL")!, + Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")! + ); + + const response = await handleDeleteUser(req, { supabaseAdmin }); + + return new Response(response.body, { + status: response.status, + headers: { ...CORS_HEADERS, ...Object.fromEntries(response.headers) }, + }); + } catch (err) { + const status = err.message === "Missing user_id" ? 400 : 500; + return new Response(JSON.stringify({ error: err.message }), { + status, + headers: CORS_HEADERS, + }); + } +}); \ No newline at end of file diff --git a/supabase/functions/delete-account/logic.test.ts b/supabase/functions/delete-account/logic.test.ts new file mode 100644 index 00000000..83a894e8 --- /dev/null +++ b/supabase/functions/delete-account/logic.test.ts @@ -0,0 +1,194 @@ +import { + assertEquals, + assertRejects, + } from "https://deno.land/std@0.182.0/testing/asserts.ts"; + import { + validateUserId, + deleteUser, + handleDeleteUser + } from "./logic.ts"; + + function makeAdminMock( + result: { data?: unknown; error?: { message: string } | null } = {} + ) { + return { + auth: { + admin: { + deleteUser: (_id: string) => + Promise.resolve({ + data: result.data ?? { user: { id: _id } }, + error: result.error ?? null, + }), + }, + }, + } as any; + } + + function makeRequest(body: unknown) { + return new Request("http://localhost/delete-user", { + method: "POST", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }); + } + + + Deno.test("validateUserId — accepts a valid UUID string", () => { + const id = "550e8400-e29b-41d4-a716-446655440000"; + assertEquals(validateUserId(id), id); + }); + + Deno.test("validateUserId — throws on null", () => { + try { + validateUserId(null); + } catch (e) { + assertEquals(e instanceof Error, true); + assertEquals((e as Error).message, "Missing user_id"); + } + }); + + Deno.test("validateUserId — throws on undefined", () => { + try { + validateUserId(undefined); + } catch (e) { + assertEquals(e instanceof Error, true); + assertEquals((e as Error).message, "Missing user_id"); + } + }); + + Deno.test("validateUserId — throws on empty string", () => { + try { + validateUserId(" "); + } catch (e) { + assertEquals(e instanceof Error, true); + assertEquals((e as Error).message, "Missing user_id"); + } + }); + + Deno.test("validateUserId — throws on non-string type", () => { + try { + validateUserId(12345); + } catch (e) { + assertEquals(e instanceof Error, true); + assertEquals((e as Error).message, "Missing user_id"); + } + }); + + + Deno.test("deleteUser — returns success and data on valid user", async () => { + const mockData = { user: { id: "user-abc" } }; + const supabaseAdmin = makeAdminMock({ data: mockData }); + + const result = await deleteUser(supabaseAdmin, "user-abc"); + + assertEquals(result.success, true); + assertEquals(result.data, mockData); + }); + + Deno.test("deleteUser — throws when Supabase returns an error", async () => { + const supabaseAdmin = makeAdminMock({ + error: { message: "User not found" }, + }); + + await assertRejects( + () => deleteUser(supabaseAdmin, "ghost-user"), + Error, + "User not found" + ); + }); + + Deno.test("deleteUser — forwards the correct user_id to Supabase", async () => { + let capturedId: string | undefined; + const supabaseAdmin = { + auth: { + admin: { + deleteUser: (id: string) => { + capturedId = id; + return Promise.resolve({ data: {}, error: null }); + }, + }, + }, + } as any; + + await deleteUser(supabaseAdmin, "user-xyz"); + assertEquals(capturedId, "user-xyz"); + }); + + Deno.test("handleDeleteUser — returns 200 with success true on valid request", async () => { + const req = makeRequest({ user_id: "user-123" }); + const res = await handleDeleteUser(req, { supabaseAdmin: makeAdminMock() }); + const body = await res.json(); + + assertEquals(res.status, 200); + assertEquals(body.success, true); + }); + + Deno.test("handleDeleteUser — response body contains data field", async () => { + const mockData = { user: { id: "user-123", email: "test@example.com" } }; + const req = makeRequest({ user_id: "user-123" }); + const res = await handleDeleteUser(req, { + supabaseAdmin: makeAdminMock({ data: mockData }), + }); + const body = await res.json(); + + assertEquals(body.data, mockData); + }); + + Deno.test("handleDeleteUser — response Content-Type is application/json", async () => { + const req = makeRequest({ user_id: "user-123" }); + const res = await handleDeleteUser(req, { supabaseAdmin: makeAdminMock() }); + + assertEquals(res.headers.get("Content-Type"), "application/json"); + }); + + Deno.test("handleDeleteUser — throws Missing user_id when body has no user_id", async () => { + const req = makeRequest({}); + + await assertRejects( + () => handleDeleteUser(req, { supabaseAdmin: makeAdminMock() }), + Error, + "Missing user_id" + ); + }); + + Deno.test("handleDeleteUser — throws Missing user_id when user_id is empty string", async () => { + const req = makeRequest({ user_id: " " }); + + await assertRejects( + () => handleDeleteUser(req, { supabaseAdmin: makeAdminMock() }), + Error, + "Missing user_id" + ); + }); + + Deno.test("handleDeleteUser — throws when Supabase admin delete fails", async () => { + const req = makeRequest({ user_id: "user-123" }); + + await assertRejects( + () => handleDeleteUser(req, { + supabaseAdmin: makeAdminMock({ error: { message: "User not found" } }), + }), + Error, + "User not found" + ); + }); + + Deno.test("handleDeleteUser — forwards the correct user_id to Supabase", async () => { + let capturedId: string | undefined; + const supabaseAdmin = { + auth: { + admin: { + deleteUser: (id: string) => { + capturedId = id; + return Promise.resolve({ data: {}, error: null }); + }, + }, + }, + } as any; + + const req = makeRequest({ user_id: "user-xyz" }); + await handleDeleteUser(req, { supabaseAdmin }); + + assertEquals(capturedId, "user-xyz"); + }); + \ No newline at end of file diff --git a/supabase/functions/delete-account/logic.ts b/supabase/functions/delete-account/logic.ts new file mode 100644 index 00000000..9ab1c7ce --- /dev/null +++ b/supabase/functions/delete-account/logic.ts @@ -0,0 +1,45 @@ +import { SupabaseClient } from "https://esm.sh/@supabase/supabase-js@2.14.0"; + +export interface DeleteUserDeps { + supabaseAdmin: SupabaseClient; +} + +export interface DeleteUserResult { + success: true; + data: unknown; +} + +export function validateUserId(user_id: unknown): string { + if (!user_id || typeof user_id !== "string" || user_id.trim() === "") { + throw new Error("Missing user_id"); + } + return user_id; +} + + +export async function deleteUser( + supabaseAdmin: SupabaseClient, + user_id: string +): Promise { + const { data, error } = await supabaseAdmin.auth.admin.deleteUser(user_id); + + if (error) { + throw new Error(error.message); + } + + return { success: true, data }; +} + +export async function handleDeleteUser( + req: Request, + deps: DeleteUserDeps +): Promise { + const body = await req.json(); + const user_id = validateUserId(body?.user_id); + const result = await deleteUser(deps.supabaseAdmin, user_id); + + return new Response(JSON.stringify(result), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} \ No newline at end of file diff --git a/supabase/functions/denyRefund/index.ts b/supabase/functions/denyRefund/index.ts new file mode 100644 index 00000000..d296c0b5 --- /dev/null +++ b/supabase/functions/denyRefund/index.ts @@ -0,0 +1,56 @@ +import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; +import { handleDenyRefund, sendDenialEmail } from "./logic.ts"; + +const CORS_HEADERS = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization, apikey", +}; + +serve(async (req) => { + if (req.method === "OPTIONS") { + return new Response(null, { status: 200, headers: CORS_HEADERS }); + } + + try { + const supabaseUrl = Deno.env.get("SUPABASE_URL"); + const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY"); + const resendKey = Deno.env.get("RESEND_API_KEY"); + + if (!supabaseUrl || !serviceKey) { + return new Response("Server configuration error", { + status: 500, + headers: CORS_HEADERS, + }); + } + + const supabase = createClient(supabaseUrl, serviceKey, { + auth: { autoRefreshToken: false, persistSession: false }, + }); + + const response = await handleDenyRefund(req, { + supabase, + sendEmail: (to, transactionId, amount) => + sendDenialEmail(resendKey!, to, transactionId, amount), + }); + + return new Response(response.body, { + status: response.status, + headers: { ...CORS_HEADERS, ...Object.fromEntries(response.headers) }, + }); + } catch (e) { + const err = e instanceof Error ? e : new Error(String(e)); + + const statusMap: Record = { + "Missing params": 400, + "User email not found": 404, + }; + const status = + err.message.startsWith("User not found") ? 404 + : err.message.startsWith("Refund update error") ? 500 + : statusMap[err.message] ?? 500; + + return new Response(`Error: ${err.message}`, { status, headers: CORS_HEADERS }); + } +}); \ No newline at end of file diff --git a/supabase/functions/denyRefund/logic.test.ts b/supabase/functions/denyRefund/logic.test.ts new file mode 100644 index 00000000..e66c93d8 --- /dev/null +++ b/supabase/functions/denyRefund/logic.test.ts @@ -0,0 +1,351 @@ +import { + assertEquals, + assertRejects, + } from "https://deno.land/std@0.168.0/testing/asserts.ts"; + import { + extractParams, + denyRefundInDb, + getUserEmail, + handleDenyRefund, + sendDenialEmail + } from "./logic.ts"; + + function makeUrl(params: Record) { + const url = new URL("http://localhost/deny-refund"); + for (const [k, v] of Object.entries(params)) { + url.searchParams.set(k, v); + } + return url; + } + + function makeFullUrl(overrides: Partial> = {}) { + return makeUrl({ + user_id: "user-123", + transaction_id: "txn-abc", + amount: "25.00", + ...overrides, + }); + } + + function makeSupabaseMock(overrides: { + updateError?: { message: string } | null; + user?: { id: string; email?: string } | null; + userError?: { message: string } | null; + } = {}) { + return { + from: (_table: string) => ({ + update: (_data: unknown) => ({ + eq: (_col: string, _val: string) => + Promise.resolve({ error: overrides.updateError ?? null }), + }), + }), + auth: { + admin: { + getUserById: (_id: string) => + Promise.resolve({ + data: { user: overrides.user !== undefined ? overrides.user : { id: "user-123", email: "user@example.com" } }, + error: overrides.userError ?? null, + }), + }, + }, + } as any; + } + + function makeRequest(url: URL) { + return new Request(url.toString(), { method: "GET" }); + } + + function mockFetch(ok: boolean, responseText = "") { + globalThis.fetch = () => + Promise.resolve({ + ok, + text: () => Promise.resolve(responseText), + } as Response); + } + + function restoreFetch() { + // Deno's real fetch is on globalThis — reset after each test + globalThis.fetch = fetch; + } + + Deno.test("extractParams — returns all three params when present", () => { + const url = makeFullUrl(); + const params = extractParams(url); + + assertEquals(params.userId, "user-123"); + assertEquals(params.transactionId, "txn-abc"); + assertEquals(params.amount, "25.00"); + }); + + Deno.test("extractParams — throws when user_id is missing", () => { + const url = makeUrl({ transaction_id: "txn-abc", amount: "25.00" }); + try { + extractParams(url); + } catch (e) { + assertEquals(e instanceof Error, true); + assertEquals((e as Error).message, "Missing params"); + } + }); + + Deno.test("extractParams — throws when transaction_id is missing", () => { + const url = makeUrl({ user_id: "user-123", amount: "25.00" }); + try { + extractParams(url); + } catch (e) { + assertEquals(e instanceof Error, true); + assertEquals((e as Error).message, "Missing params"); + } + }); + + Deno.test("extractParams — throws when amount is missing", () => { + const url = makeUrl({ user_id: "user-123", transaction_id: "txn-abc" }); + try { + extractParams(url); + } catch (e) { + assertEquals(e instanceof Error, true); + assertEquals((e as Error).message, "Missing params"); + } + }); + + Deno.test("extractParams — throws when all params are missing", () => { + const url = new URL("http://localhost/deny-refund"); + try { + extractParams(url); + } catch (e) { + assertEquals(e instanceof Error, true); + assertEquals((e as Error).message, "Missing params"); + } + }); + + Deno.test("denyRefundInDb — resolves without error on success", async () => { + const supabase = makeSupabaseMock(); + // should not throw + await denyRefundInDb(supabase, "txn-abc"); + }); + + Deno.test("denyRefundInDb — throws with prefixed message on Supabase error", async () => { + const supabase = makeSupabaseMock({ updateError: { message: "row not found" } }); + + await assertRejects( + () => denyRefundInDb(supabase, "txn-abc"), + Error, + "Refund update error: row not found" + ); + }); + + Deno.test("denyRefundInDb — passes correct transaction_id to Supabase", async () => { + let capturedVal: string | undefined; + const supabase = { + from: (_table: string) => ({ + update: (_data: unknown) => ({ + eq: (_col: string, val: string) => { + capturedVal = val; + return Promise.resolve({ error: null }); + }, + }), + }), + } as any; + + await denyRefundInDb(supabase, "txn-xyz"); + assertEquals(capturedVal, "txn-xyz"); + }); + + Deno.test("getUserEmail — returns email on valid user", async () => { + const supabase = makeSupabaseMock({ user: { id: "user-123", email: "user@example.com" } }); + + const email = await getUserEmail(supabase, "user-123"); + assertEquals(email, "user@example.com"); + }); + + Deno.test("getUserEmail — throws when user is null", async () => { + const supabase = makeSupabaseMock({ user: null }); + + await assertRejects( + () => getUserEmail(supabase, "ghost"), + Error, + "User not found" + ); + }); + + Deno.test("getUserEmail — throws when Supabase returns an error", async () => { + const supabase = makeSupabaseMock({ userError: { message: "JWT invalid" }, user: null }); + + await assertRejects( + () => getUserEmail(supabase, "user-123"), + Error, + "User not found: JWT invalid" + ); + }); + + Deno.test("getUserEmail — throws when user has no email", async () => { + const supabase = makeSupabaseMock({ user: { id: "user-123" } }); // no email field + + await assertRejects( + () => getUserEmail(supabase, "user-123"), + Error, + "User email not found" + ); + }); + + Deno.test("handleDenyRefund — returns 200 with confirmation HTML on success", async () => { + const req = makeRequest(makeFullUrl()); + const sendEmail = (_to: string, _txn: string, _amt: string) => Promise.resolve(); + + const res = await handleDenyRefund(req, { + supabase: makeSupabaseMock(), + sendEmail, + }); + const body = await res.text(); + + assertEquals(res.status, 200); + assertEquals(body.includes("txn-abc"), true); + assertEquals(body.includes("25.00"), true); + }); + + Deno.test("handleDenyRefund — calls sendEmail with correct args", async () => { + const req = makeRequest(makeFullUrl()); + let capturedArgs: [string, string, string] | undefined; + + await handleDenyRefund(req, { + supabase: makeSupabaseMock(), + sendEmail: (to, txn, amt) => { + capturedArgs = [to, txn, amt]; + return Promise.resolve(); + }, + }); + + assertEquals(capturedArgs, ["user@example.com", "txn-abc", "25.00"]); + }); + + Deno.test("handleDenyRefund — throws Missing params when query params absent", async () => { + const req = new Request("http://localhost/deny-refund", { method: "GET" }); + + await assertRejects( + () => handleDenyRefund(req, { + supabase: makeSupabaseMock(), + sendEmail: () => Promise.resolve(), + }), + Error, + "Missing params" + ); + }); + + Deno.test("handleDenyRefund — throws when DB update fails", async () => { + const req = makeRequest(makeFullUrl()); + + await assertRejects( + () => handleDenyRefund(req, { + supabase: makeSupabaseMock({ updateError: { message: "constraint violation" } }), + sendEmail: () => Promise.resolve(), + }), + Error, + "Refund update error: constraint violation" + ); + }); + + Deno.test("handleDenyRefund — throws when user is not found", async () => { + const req = makeRequest(makeFullUrl()); + + await assertRejects( + () => handleDenyRefund(req, { + supabase: makeSupabaseMock({ user: null }), + sendEmail: () => Promise.resolve(), + }), + Error, + "User not found" + ); + }); + + Deno.test("handleDenyRefund — throws when sendEmail fails", async () => { + const req = makeRequest(makeFullUrl()); + + await assertRejects( + () => handleDenyRefund(req, { + supabase: makeSupabaseMock(), + sendEmail: () => Promise.reject(new Error("Email send failed: 422")), + }), + Error, + "Email send failed" + ); + }); + + Deno.test("sendDenialEmail — resolves without error on success", async () => { + mockFetch(true); + try { + await sendDenialEmail("test-api-key", "user@example.com", "txn-abc", "25.00"); + } finally { + restoreFetch(); + } + // reaching here without throwing is the assertion + }); + + Deno.test("sendDenialEmail — throws with error text when response is not ok", async () => { + mockFetch(false, "Invalid API key"); + try { + await assertRejects( + () => sendDenialEmail("bad-key", "user@example.com", "txn-abc", "25.00"), + Error, + "Email send failed: Invalid API key" + ); + } finally { + restoreFetch(); + } + }); + + Deno.test("sendDenialEmail — sends POST to the correct Resend endpoint", async () => { + let capturedUrl: string | undefined; + let capturedInit: RequestInit | undefined; + + globalThis.fetch = (url: string | URL | Request, init?: RequestInit) => { + capturedUrl = url.toString(); + capturedInit = init; + return Promise.resolve({ ok: true, text: () => Promise.resolve("") } as Response); + }; + + try { + await sendDenialEmail("test-key", "user@example.com", "txn-abc", "25.00"); + } finally { + restoreFetch(); + } + + assertEquals(capturedUrl, "https://api.resend.com/emails"); + assertEquals(capturedInit?.method, "POST"); + }); + + Deno.test("sendDenialEmail — sends correct Authorization header", async () => { + let capturedHeaders: Record | undefined; + + globalThis.fetch = (_url: string | URL | Request, init?: RequestInit) => { + capturedHeaders = init?.headers as Record; + return Promise.resolve({ ok: true, text: () => Promise.resolve("") } as Response); + }; + + try { + await sendDenialEmail("my-resend-key", "user@example.com", "txn-abc", "25.00"); + } finally { + restoreFetch(); + } + + assertEquals(capturedHeaders?.["Authorization"], "Bearer my-resend-key"); + assertEquals(capturedHeaders?.["Content-Type"], "application/json"); + }); + + Deno.test("sendDenialEmail — sends correct recipient, transactionId, and amount in body", async () => { + let capturedBody: any; + + globalThis.fetch = (_url: string | URL | Request, init?: RequestInit) => { + capturedBody = JSON.parse(init?.body as string); + return Promise.resolve({ ok: true, text: () => Promise.resolve("") } as Response); + }; + + try { + await sendDenialEmail("test-key", "customer@example.com", "txn-xyz", "49.99"); + } finally { + restoreFetch(); + } + + assertEquals(capturedBody.to, "customer@example.com"); + assertEquals(capturedBody.html.includes("txn-xyz"), true); + assertEquals(capturedBody.html.includes("49.99"), true); + assertEquals(capturedBody.subject, "Refund Request Denied"); + }); \ No newline at end of file diff --git a/supabase/functions/denyRefund/logic.ts b/supabase/functions/denyRefund/logic.ts new file mode 100644 index 00000000..5020cadb --- /dev/null +++ b/supabase/functions/denyRefund/logic.ts @@ -0,0 +1,115 @@ +import { SupabaseClient } from "https://esm.sh/@supabase/supabase-js@2"; + +export interface DenyRefundParams { + userId: string; + transactionId: string; + amount: string; +} + +export interface DenyRefundDeps { + supabase: SupabaseClient; + sendEmail: (to: string, transactionId: string, amount: string) => Promise; +} + +export function extractParams(url: URL): DenyRefundParams { + const userId = url.searchParams.get("user_id"); + const transactionId = url.searchParams.get("transaction_id"); + const amount = url.searchParams.get("amount"); + + if (!userId || !transactionId || !amount) { + throw new Error("Missing params"); + } + + return { userId, transactionId, amount }; +} + +export async function denyRefundInDb( + supabase: SupabaseClient, + transactionId: string +): Promise { + const { error } = await supabase + .from("Refunds") + .update({ status: "denied" }) + .eq("transaction_id", transactionId); + + if (error) { + throw new Error(`Refund update error: ${error.message}`); + } +} + +export async function getUserEmail( + supabase: SupabaseClient, + userId: string +): Promise { + const { + data: { user }, + error, + } = await supabase.auth.admin.getUserById(userId); + + if (error || !user) { + throw new Error(`User not found: ${error?.message ?? "No data"}`); + } + + if (!user.email) { + throw new Error("User email not found"); + } + + return user.email; +} + +export async function sendDenialEmail( + resendApiKey: string, + to: string, + transactionId: string, + amount: string +): Promise { + const response = await fetch("https://api.resend.com/emails", { + method: "POST", + headers: { + Authorization: `Bearer ${resendApiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + from: "refund@updates.cleanstreamlaundry.com", + to, + subject: "Refund Request Denied", + html: ` +

Refund Request Denied

+

Unfortunately, your refund request for transaction ${transactionId} has been denied.

+

Amount: $${amount}

+

If you have questions about this decision, please contact support.

+ `, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Email send failed: ${errorText}`); + } +} + +export async function handleDenyRefund( + req: Request, + deps: DenyRefundDeps +): Promise { + const url = new URL(req.url); + const { userId, transactionId, amount } = extractParams(url); + + await denyRefundInDb(deps.supabase, transactionId); + + const userEmail = await getUserEmail(deps.supabase, userId); + + await deps.sendEmail(userEmail, transactionId, amount); + + const html = `Refund Denied + The refund request has been denied and + the customer has been notified via email. + Transaction: ${transactionId} + Amount: $${amount} + `; + + return new Response(html, { + status: 200, + headers: { "Content-Type": "text/html" }, + }); +} \ No newline at end of file diff --git a/supabase/functions/paymentIntent/index.ts b/supabase/functions/paymentIntent/index.ts new file mode 100644 index 00000000..8b85ae0c --- /dev/null +++ b/supabase/functions/paymentIntent/index.ts @@ -0,0 +1,40 @@ +import Stripe from "npm:stripe"; +import { handleCreatePaymentIntent } from "./logic.ts"; + +const CORS_HEADERS = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization, apikey, x-client-info", +}; + +const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY")!, { + apiVersion: "2024-06-20", +}); + +Deno.serve(async (req) => { + if (req.method === "OPTIONS") { + return new Response(null, { headers: CORS_HEADERS }); + } + + if (req.method !== "POST") { + return new Response("Method Not Allowed", { + status: 405, + headers: CORS_HEADERS, + }); + } + + try { + const response = await handleCreatePaymentIntent(req, { stripe }); + + return new Response(response.body, { + status: response.status, + headers: { ...CORS_HEADERS, ...Object.fromEntries(response.headers) }, + }); + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + return new Response(JSON.stringify({ error: error.message ?? "Unknown error" }), { + status: 400, + headers: { ...CORS_HEADERS, "Content-Type": "application/json" }, + }); + } +}); \ No newline at end of file diff --git a/supabase/functions/paymentIntent/logic.test.ts b/supabase/functions/paymentIntent/logic.test.ts new file mode 100644 index 00000000..93fc7fb1 --- /dev/null +++ b/supabase/functions/paymentIntent/logic.test.ts @@ -0,0 +1,189 @@ +import { + assertEquals, + assertRejects, + } from "https://deno.land/std@0.168.0/testing/asserts.ts"; + import { + validateAmount, + createPaymentIntent, + handleCreatePaymentIntent, + } from "./logic.ts"; + + function makeStripeMock(overrides: { + clientSecret?: string | null; + throwMessage?: string; + } = {}) { + return { + paymentIntents: { + create: (_params: unknown) => { + if (overrides.throwMessage) { + return Promise.reject(new Error(overrides.throwMessage)); + } + const clientSecret = "clientSecret" in overrides + ? overrides.clientSecret + : "pi_test_secret_abc123"; + return Promise.resolve({ client_secret: clientSecret }); + }, + }, + } as any; + } + + function makeRequest(body: unknown) { + return new Request("http://localhost/create-payment-intent", { + method: "POST", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }); + } + + Deno.test("validateAmount — accepts a valid positive integer", () => { + assertEquals(validateAmount(2500), 2500); + }); + + Deno.test("validateAmount — accepts the minimum valid amount (1 cent)", () => { + assertEquals(validateAmount(1), 1); + }); + + Deno.test("validateAmount — throws on undefined", () => { + try { + validateAmount(undefined); + } catch (e) { + assertEquals(e instanceof Error, true); + assertEquals((e as Error).message, "Missing amount"); + } + }); + + Deno.test("validateAmount — throws on null", () => { + try { + validateAmount(null); + } catch (e) { + assertEquals(e instanceof Error, true); + assertEquals((e as Error).message, "Missing amount"); + } + }); + + Deno.test("validateAmount — throws on zero", () => { + try { + validateAmount(0); + } catch (e) { + assertEquals(e instanceof Error, true); + assertEquals((e as Error).message.startsWith("Invalid amount"), true); + } + }); + + Deno.test("validateAmount — throws on negative number", () => { + try { + validateAmount(-500); + } catch (e) { + assertEquals(e instanceof Error, true); + assertEquals((e as Error).message.startsWith("Invalid amount"), true); + } + }); + + Deno.test("validateAmount — throws on float (Stripe requires whole cents)", () => { + try { + validateAmount(24.99); + } catch (e) { + assertEquals(e instanceof Error, true); + assertEquals((e as Error).message.startsWith("Invalid amount"), true); + } + }); + + Deno.test("validateAmount — throws on string", () => { + try { + validateAmount("2500"); + } catch (e) { + assertEquals(e instanceof Error, true); + assertEquals((e as Error).message.startsWith("Invalid amount"), true); + } + }); + + Deno.test("createPaymentIntent — returns clientSecret on success", async () => { + const stripe = makeStripeMock(); + const result = await createPaymentIntent(stripe, 2500); + + assertEquals(result.clientSecret, "pi_test_secret_abc123"); + }); + + Deno.test("createPaymentIntent — passes correct amount to Stripe", async () => { + let capturedParams: any; + const stripe = { + paymentIntents: { + create: (params: unknown) => { + capturedParams = params; + return Promise.resolve({ client_secret: "pi_test_secret" }); + }, + }, + } as any; + + await createPaymentIntent(stripe, 4999); + + assertEquals(capturedParams.amount, 4999); + assertEquals(capturedParams.currency, "usd"); + assertEquals(capturedParams.payment_method_types, ["card"]); + }); + + Deno.test("createPaymentIntent — handles null client_secret from Stripe", async () => { + const stripe = makeStripeMock({ clientSecret: null }); + const result = await createPaymentIntent(stripe, 2500); + + assertEquals(result.clientSecret, null); + }); + + Deno.test("createPaymentIntent — throws when Stripe rejects", async () => { + const stripe = makeStripeMock({ throwMessage: "Your card was declined." }); + + await assertRejects( + () => createPaymentIntent(stripe, 2500), + Error, + "Your card was declined." + ); + }); + + + Deno.test("handleCreatePaymentIntent — returns 200 with clientSecret", async () => { + const req = makeRequest({ amount: 2500 }); + const res = await handleCreatePaymentIntent(req, { stripe: makeStripeMock() }); + const body = await res.json(); + + assertEquals(res.status, 200); + assertEquals(body.clientSecret, "pi_test_secret_abc123"); + }); + + Deno.test("handleCreatePaymentIntent — throws Missing amount when body has no amount", async () => { + const req = makeRequest({}); + + await assertRejects( + () => handleCreatePaymentIntent(req, { stripe: makeStripeMock() }), + Error, + "Missing amount" + ); + }); + + Deno.test("handleCreatePaymentIntent — throws Invalid amount for a float", async () => { + const req = makeRequest({ amount: 19.99 }); + + await assertRejects( + () => handleCreatePaymentIntent(req, { stripe: makeStripeMock() }), + Error, + "Invalid amount" + ); + }); + + Deno.test("handleCreatePaymentIntent — throws when Stripe fails", async () => { + const req = makeRequest({ amount: 2500 }); + + await assertRejects( + () => handleCreatePaymentIntent(req, { + stripe: makeStripeMock({ throwMessage: "Stripe API unavailable" }), + }), + Error, + "Stripe API unavailable" + ); + }); + + Deno.test("handleCreatePaymentIntent — response Content-Type is application/json", async () => { + const req = makeRequest({ amount: 1000 }); + const res = await handleCreatePaymentIntent(req, { stripe: makeStripeMock() }); + + assertEquals(res.headers.get("Content-Type"), "application/json"); + }); \ No newline at end of file diff --git a/supabase/functions/paymentIntent/logic.ts b/supabase/functions/paymentIntent/logic.ts new file mode 100644 index 00000000..a308762b --- /dev/null +++ b/supabase/functions/paymentIntent/logic.ts @@ -0,0 +1,46 @@ +import Stripe from "npm:stripe"; + +export interface PaymentIntentDeps { + stripe: Stripe; +} + +export interface PaymentIntentResult { + clientSecret: string | null; +} + +export function validateAmount(amount: unknown): number { + if (amount === undefined || amount === null) { + throw new Error("Missing amount"); + } + if (typeof amount !== "number" || !Number.isInteger(amount) || amount <= 0) { + throw new Error("Invalid amount: must be a positive integer (cents)"); + } + return amount; +} + +export async function createPaymentIntent( + stripe: Stripe, + amount: number +): Promise { + const intent = await stripe.paymentIntents.create({ + amount, + currency: "usd", + payment_method_types: ["card"], + }); + + return { clientSecret: intent.client_secret }; +} + +export async function handleCreatePaymentIntent( + req: Request, + deps: PaymentIntentDeps +): Promise { + const body = await req.json(); + const amount = validateAmount(body?.amount); + const result = await createPaymentIntent(deps.stripe, amount); + + return new Response(JSON.stringify(result), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} \ No newline at end of file diff --git a/supabase/functions/ping-device/index.ts b/supabase/functions/ping-device/index.ts new file mode 100644 index 00000000..d62c2285 --- /dev/null +++ b/supabase/functions/ping-device/index.ts @@ -0,0 +1,77 @@ +import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; +import { handleMachineRequest } from "./logic.ts"; + +const supabaseUrl = Deno.env.get("SUPABASE_URL")!; +const anonKey = Deno.env.get("SUPABASE_ANON_KEY")!; +const supabase = createClient(supabaseUrl, anonKey); + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", + "Access-Control-Allow-Headers": "*", + "Access-Control-Max-Age": "86400", +}; + +async function getMachineStatusFromSupabase(deviceId: string) { + try { + const { data, error } = await supabase + .from("Machines") + .select("Status") + .eq("id", deviceId) + .single(); + + if (error || !data) return "error"; + + const status = data.Status?.toLowerCase(); + + const valid = ["idle", "in-use", "offline", "error"]; + + return valid.includes(status) ? status : "offline"; + } catch { + return "error"; + } +} + +serve(async (req) => { + if (req.method === "OPTIONS") { + return new Response(null, { + status: 200, + headers: corsHeaders, + }); + } + + try { + const text = await req.text(); + const body = text ? JSON.parse(text) : {}; + + const result = await handleMachineRequest(body, { + getMachineStatus: getMachineStatusFromSupabase, + random: Math.random, + delay: (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)), + }); + + return new Response(JSON.stringify(result.body), { + status: result.status, + headers: { + "Content-Type": "application/json", + ...corsHeaders, + }, + }); + } catch (error: any) { + return new Response( + JSON.stringify({ + success: false, + error: error.message || "Internal server error", + }), + { + status: 500, + headers: { + "Content-Type": "application/json", + ...corsHeaders, + }, + } + ); + } +}); \ No newline at end of file diff --git a/supabase/functions/ping-device/logic.test.ts b/supabase/functions/ping-device/logic.test.ts new file mode 100644 index 00000000..2c5b2fef --- /dev/null +++ b/supabase/functions/ping-device/logic.test.ts @@ -0,0 +1,61 @@ +export type MachineStatus = + | "idle" + | "in-use" + | "offline" + | "error"; + +export interface Dependencies { + getMachineStatus: (deviceId: string) => Promise; + random: () => number; + delay: (ms: number) => Promise; +} + +export async function handleMachineRequest( + body: any, + deps: Dependencies +) { + const { getMachineStatus, random, delay } = deps; + + const deviceId = body?.deviceId; + + if (!deviceId) { + return { + status: 400, + body: { + error: "deviceId is required", + receivedBody: body, + }, + }; + } + + const success = random() < 0.95; + const responseDelay = Math.floor(random() * 150) + 50; + + await delay(responseDelay); + + const machineStatus = await getMachineStatus(deviceId); + + if (success) { + return { + status: 200, + body: { + success: true, + deviceId, + message: machineStatus, + timestamp: new Date().toISOString(), + responseTime: `${responseDelay}ms`, + }, + }; + } + + return { + status: 503, + body: { + success: false, + deviceId, + error: "Device unreachable or timeout", + timestamp: new Date().toISOString(), + responseTime: `${responseDelay}ms`, + }, + }; +} \ No newline at end of file diff --git a/supabase/functions/ping-device/logic.ts b/supabase/functions/ping-device/logic.ts new file mode 100644 index 00000000..2c5b2fef --- /dev/null +++ b/supabase/functions/ping-device/logic.ts @@ -0,0 +1,61 @@ +export type MachineStatus = + | "idle" + | "in-use" + | "offline" + | "error"; + +export interface Dependencies { + getMachineStatus: (deviceId: string) => Promise; + random: () => number; + delay: (ms: number) => Promise; +} + +export async function handleMachineRequest( + body: any, + deps: Dependencies +) { + const { getMachineStatus, random, delay } = deps; + + const deviceId = body?.deviceId; + + if (!deviceId) { + return { + status: 400, + body: { + error: "deviceId is required", + receivedBody: body, + }, + }; + } + + const success = random() < 0.95; + const responseDelay = Math.floor(random() * 150) + 50; + + await delay(responseDelay); + + const machineStatus = await getMachineStatus(deviceId); + + if (success) { + return { + status: 200, + body: { + success: true, + deviceId, + message: machineStatus, + timestamp: new Date().toISOString(), + responseTime: `${responseDelay}ms`, + }, + }; + } + + return { + status: 503, + body: { + success: false, + deviceId, + error: "Device unreachable or timeout", + timestamp: new Date().toISOString(), + responseTime: `${responseDelay}ms`, + }, + }; +} \ No newline at end of file diff --git a/supabase/functions/refund-email/index.ts b/supabase/functions/refund-email/index.ts new file mode 100644 index 00000000..1fe7392f --- /dev/null +++ b/supabase/functions/refund-email/index.ts @@ -0,0 +1,85 @@ +import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; +import { handleRefundRequest } from "./logic.ts"; + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", + "Access-Control-Allow-Headers": "*", + "Access-Control-Max-Age": "86400", +}; + +serve(async (req) => { + if (req.method === "OPTIONS") { + return new Response(null, { + status: 200, + headers: corsHeaders, + }); + } + + try { + let body; + + try { + const text = await req.text(); + body = text ? JSON.parse(text) : {}; + } catch { + return new Response( + JSON.stringify({ error: "Invalid JSON body" }), + { + status: 400, + headers: { + "Content-Type": "application/json", + ...corsHeaders, + }, + } + ); + } + + const RESEND_API_KEY = Deno.env.get("RESEND_API_KEY")!; + + const result = await handleRefundRequest(body, { + sendEmail: async ({ subject, html }) => { + const response = await fetch( + "https://api.resend.com/emails", + { + method: "POST", + headers: { + Authorization: `Bearer ${RESEND_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + from: "refund@updates.cleanstreamlaundry.com", + to: "yoder453@gmail.com", + subject, + html, + }), + } + ); + + return response.json(); + }, + }); + + return new Response(JSON.stringify(result.body), { + status: result.status, + headers: { + "Content-Type": "application/json", + ...corsHeaders, + }, + }); + } catch (error: any) { + return new Response( + JSON.stringify({ + success: false, + error: error.message || "Internal server error", + }), + { + status: 500, + headers: { + "Content-Type": "application/json", + ...corsHeaders, + }, + } + ); + } +}); \ No newline at end of file diff --git a/supabase/functions/refund-email/logic.test.ts b/supabase/functions/refund-email/logic.test.ts new file mode 100644 index 00000000..5ecf6fc0 --- /dev/null +++ b/supabase/functions/refund-email/logic.test.ts @@ -0,0 +1,89 @@ +import { + handleRefundRequest, + } from "./logic.ts"; + + import { + assertEquals, + assert, + } from "https://deno.land/std@0.224.0/testing/asserts.ts"; + + function createDeps() { + let capturedSubject = ""; + let capturedHtml = ""; + + return { + deps: { + sendEmail: async ({ subject, html }: any) => { + capturedSubject = subject; + capturedHtml = html; + return { id: "email_123" }; + }, + }, + getCaptured: () => ({ + subject: capturedSubject, + html: capturedHtml, + }), + }; + } + + const validBody = { + username: "John", + user_id: "u123", + transaction_id: "t456", + amount: 25, + description: "Machine ate my sock", + userAttempts: 2, + }; + + Deno.test("returns 400 if required fields missing", async () => { + const { deps } = createDeps(); + + const result = await handleRefundRequest({}, deps); + + assertEquals(result.status, 400); + assertEquals(result.body.error, "Missing required fields"); + }); + + Deno.test("returns 200 on valid request", async () => { + const { deps } = createDeps(); + + const result = await handleRefundRequest(validBody, deps); + + assertEquals(result.status, 200); + assertEquals(result.body.success, true); + assertEquals(result.body.resend.id, "email_123"); + }); + + Deno.test("email subject contains username", async () => { + const { deps, getCaptured } = createDeps(); + + await handleRefundRequest(validBody, deps); + + const { subject } = getCaptured(); + + assert(subject.includes("John")); + }); + + Deno.test("email contains approve and deny links", async () => { + const { deps, getCaptured } = createDeps(); + + await handleRefundRequest(validBody, deps); + + const { html } = getCaptured(); + + assert(html.includes("approveRefund")); + assert(html.includes("denyRefund")); + assert(html.includes("u123")); + assert(html.includes("t456")); + }); + + Deno.test("email includes refund details", async () => { + const { deps, getCaptured } = createDeps(); + + await handleRefundRequest(validBody, deps); + + const { html } = getCaptured(); + + assert(html.includes("Machine ate my sock")); + assert(html.includes("$25")); + }); \ No newline at end of file diff --git a/supabase/functions/refund-email/logic.ts b/supabase/functions/refund-email/logic.ts new file mode 100644 index 00000000..9e9c8b7c --- /dev/null +++ b/supabase/functions/refund-email/logic.ts @@ -0,0 +1,76 @@ +export interface RefundRequestBody { + username?: string; + user_id?: string; + transaction_id?: string; + amount?: number; + description?: string; + userAttempts?: number; + } + + export interface Dependencies { + sendEmail: (params: { + subject: string; + html: string; + }) => Promise; + } + + export async function handleRefundRequest( + body: RefundRequestBody, + deps: Dependencies + ) { + const { sendEmail } = deps; + + const { + username, + user_id, + transaction_id, + amount, + description, + userAttempts, + } = body; + + if (!username || !user_id || !transaction_id || !amount) { + return { + status: 400, + body: { + error: "Missing required fields", + received: body, + }, + }; + } + + const approveLink = + `https://dnuuhupoxjtwqzaqylvb.supabase.co/functions/v1/approveRefund` + + `?user_id=${user_id}&transaction_id=${transaction_id}&amount=${amount}`; + + const denyLink = + `https://dnuuhupoxjtwqzaqylvb.supabase.co/functions/v1/denyRefund` + + `?user_id=${user_id}&transaction_id=${transaction_id}&amount=${amount}`; + + const emailBody = ` +

Refund Request Received

+

Name: ${username}

+

User ID: ${user_id}

+

Transaction ID: ${transaction_id}

+

Amount: $${amount}

+

Reason: ${description}

+

Number of refund attempts: ${userAttempts}

+ + `; + + const emailResult = await sendEmail({ + subject: `New Refund Request - ${username}`, + html: emailBody, + }); + + return { + status: 200, + body: { + success: true, + resend: emailResult, + }, + }; + } \ No newline at end of file diff --git a/supabase/functions/resetToken/index.ts b/supabase/functions/resetToken/index.ts new file mode 100644 index 00000000..9ac75465 --- /dev/null +++ b/supabase/functions/resetToken/index.ts @@ -0,0 +1,29 @@ +import { serve } from "https://deno.land/std@0.224.0/http/server.ts"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; +import { handleExchangeCode } from "./logic.ts"; + +serve(async (req) => { + try { + const body = await req.json(); + + const supabase = createClient( + Deno.env.get("SUPABASE_URL")!, + Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!, + ); + + const result = await handleExchangeCode(body, { + exchangeCodeForSession: async (code: string) => { + const { data, error } = + await supabase.auth.exchangeCodeForSession(code); + + if (error) return {}; + return { user: data?.user }; + }, + }); + + return new Response(result.body, { status: result.status }); + + } catch { + return new Response("Bad Request", { status: 400 }); + } +}); \ No newline at end of file diff --git a/supabase/functions/resetToken/logic.test.ts b/supabase/functions/resetToken/logic.test.ts new file mode 100644 index 00000000..5ec3b9c0 --- /dev/null +++ b/supabase/functions/resetToken/logic.test.ts @@ -0,0 +1,61 @@ +import { + handleExchangeCode, + } from "./logic.ts"; + + import { + assertEquals, + } from "https://deno.land/std@0.224.0/testing/asserts.ts"; + + + function createDeps(options?: { + user?: any; + }) { + return { + exchangeCodeForSession: async (_code: string) => { + return options?.user ? { user: options.user } : {}; + }, + }; + } + + + Deno.test("returns 400 if code missing", async () => { + const result = await handleExchangeCode({}, createDeps()); + + assertEquals(result.status, 400); + assertEquals(result.body, "Missing code"); + }); + + Deno.test("returns 401 if no user returned", async () => { + const result = await handleExchangeCode( + { code: "abc" }, + createDeps() + ); + + assertEquals(result.status, 401); + assertEquals(result.body, "Invalid or expired code"); + }); + + Deno.test("returns 200 if user exists", async () => { + const result = await handleExchangeCode( + { code: "abc" }, + createDeps({ user: { id: "123" } }) + ); + + assertEquals(result.status, 200); + assertEquals(result.body, "OK"); + }); + + Deno.test("calls dependency with correct code", async () => { + let capturedCode = ""; + + const deps = { + exchangeCodeForSession: async (code: string) => { + capturedCode = code; + return { user: { id: "123" } }; + }, + }; + + await handleExchangeCode({ code: "specialCode" }, deps); + + assertEquals(capturedCode, "specialCode"); + }); \ No newline at end of file diff --git a/supabase/functions/resetToken/logic.ts b/supabase/functions/resetToken/logic.ts new file mode 100644 index 00000000..6178d5ee --- /dev/null +++ b/supabase/functions/resetToken/logic.ts @@ -0,0 +1,26 @@ +export interface Dependencies { + exchangeCodeForSession: ( + code: string + ) => Promise<{ user?: any }>; + } + + export async function handleExchangeCode( + body: any, + deps: Dependencies + ) { + const { exchangeCodeForSession } = deps; + + const code = body?.code; + + if (!code) { + return { status: 400, body: "Missing code" }; + } + + const result = await exchangeCodeForSession(code); + + if (!result?.user) { + return { status: 401, body: "Invalid or expired code" }; + } + + return { status: 200, body: "OK" }; + } \ No newline at end of file diff --git a/supabase/functions/stripeWebhook/index.ts b/supabase/functions/stripeWebhook/index.ts new file mode 100644 index 00000000..af9598e6 --- /dev/null +++ b/supabase/functions/stripeWebhook/index.ts @@ -0,0 +1,46 @@ +import { serve } from "https://deno.land/std@0.223.0/http/server.ts"; +import Stripe from "npm:stripe@14"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; +import { handleStripeWebhook } from "./logic.ts"; + +const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY")!, { + apiVersion: "2023-10-16", + httpClient: Stripe.createFetchHttpClient(), +}); + +serve(async (req) => { + const signature = req.headers.get("stripe-signature"); + const rawBody = await req.text(); + + const webhookSecret = Deno.env.get("STRIPE_WEBHOOK_SECRET")!; + + const result = await handleStripeWebhook( + { rawBody, signature }, + { + verifyAndConstructEvent: async (body, sig) => { + return await stripe.webhooks.constructEventAsync( + body, + sig, + webhookSecret, + undefined, + Stripe.createSubtleCryptoProvider() + ); + }, + + broadcastPaymentSuccess: async (payload) => { + const supabase = createClient( + Deno.env.get("SUPABASE_URL")!, + Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")! + ); + + await supabase.channel("payments").send({ + type: "broadcast", + event: "payment_success", + payload, + }); + }, + } + ); + + return new Response(result.body, { status: result.status }); +}); \ No newline at end of file diff --git a/supabase/functions/stripeWebhook/logic.test.ts b/supabase/functions/stripeWebhook/logic.test.ts new file mode 100644 index 00000000..29c673a5 --- /dev/null +++ b/supabase/functions/stripeWebhook/logic.test.ts @@ -0,0 +1,105 @@ +import { + handleStripeWebhook, + } from "./logic.ts"; + + import { + assertEquals, + assert, + } from "https://deno.land/std@0.224.0/testing/asserts.ts"; + + + function createDeps(options?: { + eventType?: string; + shouldThrow?: boolean; + }) { + let broadcastCalled = false; + let broadcastPayload: any = null; + + return { + deps: { + verifyAndConstructEvent: async () => { + if (options?.shouldThrow) { + throw new Error("Invalid signature"); + } + + return { + type: options?.eventType ?? "checkout.session.completed", + data: { + object: { + metadata: { user_id: "user123" }, + amount_total: 5000, + }, + }, + }; + }, + + broadcastPaymentSuccess: async (payload: any) => { + broadcastCalled = true; + broadcastPayload = payload; + }, + }, + + getBroadcastInfo: () => ({ + broadcastCalled, + broadcastPayload, + }), + }; + } + + + Deno.test("returns 400 if signature missing", async () => { + const { deps } = createDeps(); + + const result = await handleStripeWebhook( + { rawBody: "{}", signature: null }, + deps + ); + + assertEquals(result.status, 400); + assertEquals(result.body, "No signature"); + }); + + Deno.test("returns 400 if verification fails", async () => { + const { deps } = createDeps({ shouldThrow: true }); + + const result = await handleStripeWebhook( + { rawBody: "{}", signature: "sig" }, + deps + ); + + assertEquals(result.status, 400); + assert(result.body.includes("Invalid signature")); + }); + + Deno.test("broadcasts on checkout.session.completed", async () => { + const { deps, getBroadcastInfo } = createDeps(); + + const result = await handleStripeWebhook( + { rawBody: "{}", signature: "sig" }, + deps + ); + + const { broadcastCalled, broadcastPayload } = + getBroadcastInfo(); + + assertEquals(result.status, 200); + assertEquals(broadcastCalled, true); + assertEquals(broadcastPayload.user_id, "user123"); + assertEquals(broadcastPayload.amount, 5000); + }); + + Deno.test("does not broadcast for other event types", async () => { + const { deps, getBroadcastInfo } = createDeps({ + eventType: "payment.failed", + }); + + const result = await handleStripeWebhook( + { rawBody: "{}", signature: "sig" }, + deps + ); + + const { broadcastCalled } = getBroadcastInfo(); + + assertEquals(result.status, 200); + assertEquals(broadcastCalled, false); + }); \ No newline at end of file diff --git a/supabase/functions/stripeWebhook/logic.ts b/supabase/functions/stripeWebhook/logic.ts new file mode 100644 index 00000000..ca5d2d07 --- /dev/null +++ b/supabase/functions/stripeWebhook/logic.ts @@ -0,0 +1,51 @@ +export interface StripeEvent { + type: string; + data: { + object: any; + }; + } + + export interface Dependencies { + verifyAndConstructEvent: (rawBody: string, signature: string) => Promise; + broadcastPaymentSuccess: (payload: { + user_id?: string; + amount?: number; + }) => Promise; + } + + export async function handleStripeWebhook( + params: { + rawBody: string; + signature: string | null; + }, + deps: Dependencies + ) { + const { rawBody, signature } = params; + const { verifyAndConstructEvent, broadcastPaymentSuccess } = deps; + + if (!signature) { + return { status: 400, body: "No signature" }; + } + + let event: StripeEvent; + + try { + event = await verifyAndConstructEvent(rawBody, signature); + } catch (err: any) { + return { + status: 400, + body: `Webhook Error: ${err.message}`, + }; + } + + if (event.type === "checkout.session.completed") { + const session = event.data.object; + + await broadcastPaymentSuccess({ + user_id: session.metadata?.user_id, + amount: session.amount_total, + }); + } + + return { status: 200, body: "OK" }; + } \ No newline at end of file diff --git a/supabase/functions/verifyPayment/index.ts b/supabase/functions/verifyPayment/index.ts new file mode 100644 index 00000000..273ccf59 --- /dev/null +++ b/supabase/functions/verifyPayment/index.ts @@ -0,0 +1,29 @@ +import Stripe from "npm:stripe@^14.0.0"; +import { serve } from "https://deno.land/std/http/server.ts"; +import { handleCheckPaymentResult } from "./logic.ts"; + +const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY")!, { + apiVersion: "2023-10-16", +}); + +serve(async (req) => { + try { + const body = await req.json(); + + const result = await handleCheckPaymentResult(body, { + retrieveSession: async (sessionId: string) => { + return await stripe.checkout.sessions.retrieve(sessionId); + }, + }); + + return new Response(JSON.stringify(result.body), { + status: result.status, + headers: { "Content-Type": "application/json" }, + }); + } catch { + return new Response( + JSON.stringify({ error: "Bad Request" }), + { status: 400 } + ); + } +}); \ No newline at end of file diff --git a/supabase/functions/verifyPayment/logic.test.ts b/supabase/functions/verifyPayment/logic.test.ts new file mode 100644 index 00000000..4e8e7c01 --- /dev/null +++ b/supabase/functions/verifyPayment/logic.test.ts @@ -0,0 +1,89 @@ +import { + handleCheckPaymentResult, + } from "./logic.ts"; + + import { + assertEquals, + } from "https://deno.land/std@0.224.0/testing/asserts.ts"; + + + function createDeps(options?: { + paymentStatus?: string; + shouldThrow?: boolean; + }) { + let capturedSessionId = ""; + + return { + deps: { + retrieveSession: async (sessionId: string) => { + capturedSessionId = sessionId; + + if (options?.shouldThrow) { + throw new Error("Stripe error"); + } + + return { + payment_status: options?.paymentStatus ?? "unpaid", + }; + }, + }, + getCapturedSessionId: () => capturedSessionId, + }; + } + + + Deno.test("returns 400 if session_id missing", async () => { + const { deps } = createDeps(); + + const result = await handleCheckPaymentResult({}, deps); + + assertEquals(result.status, 400); + assertEquals(result.body.error, "Missing session_id"); + }); + + Deno.test("returns paid: true when payment_status is paid", async () => { + const { deps } = createDeps({ paymentStatus: "paid" }); + + const result = await handleCheckPaymentResult( + { session_id: "sess_123" }, + deps + ); + + assertEquals(result.status, 200); + assertEquals(result.body.paid, true); + }); + + Deno.test("returns paid: false when payment_status is not paid", async () => { + const { deps } = createDeps({ paymentStatus: "unpaid" }); + + const result = await handleCheckPaymentResult( + { session_id: "sess_123" }, + deps + ); + + assertEquals(result.status, 200); + assertEquals(result.body.paid, false); + }); + + Deno.test("calls retrieveSession with correct session_id", async () => { + const { deps, getCapturedSessionId } = createDeps(); + + await handleCheckPaymentResult( + { session_id: "sess_abc" }, + deps + ); + + assertEquals(getCapturedSessionId(), "sess_abc"); + }); + + Deno.test("returns 400 if Stripe throws", async () => { + const { deps } = createDeps({ shouldThrow: true }); + + const result = await handleCheckPaymentResult( + { session_id: "sess_123" }, + deps + ); + + assertEquals(result.status, 400); + assertEquals(result.body.error, "Stripe error"); + }); \ No newline at end of file diff --git a/supabase/functions/verifyPayment/logic.ts b/supabase/functions/verifyPayment/logic.ts new file mode 100644 index 00000000..216503f8 --- /dev/null +++ b/supabase/functions/verifyPayment/logic.ts @@ -0,0 +1,41 @@ +export interface StripeSession { + payment_status?: string; + } + + export interface Dependencies { + retrieveSession: (sessionId: string) => Promise; + } + + export async function handleCheckPaymentResult( + body: any, + deps: Dependencies + ) { + const { retrieveSession } = deps; + + const sessionId = body?.session_id; + + if (!sessionId) { + return { + status: 400, + body: { error: "Missing session_id" }, + }; + } + + try { + const session = await retrieveSession(sessionId); + + return { + status: 200, + body: { + paid: session.payment_status === "paid", + }, + }; + } catch (err: any) { + return { + status: 400, + body: { + error: err.message || "Failed to retrieve session", + }, + }; + } + } \ No newline at end of file diff --git a/supabase/functions/wakeDevice/index.ts b/supabase/functions/wakeDevice/index.ts new file mode 100644 index 00000000..52fe6d7b --- /dev/null +++ b/supabase/functions/wakeDevice/index.ts @@ -0,0 +1,66 @@ +import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; +import { handleWakeDevice } from "./logic.ts"; + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", + "Access-Control-Allow-Headers": "*", + "Access-Control-Max-Age": "86400", +}; + +serve(async (req) => { + if (req.method === "OPTIONS") { + return new Response(null, { + status: 200, + headers: corsHeaders, + }); + } + + try { + let body; + try { + const text = await req.text(); + body = text ? JSON.parse(text) : {}; + } catch { + return new Response( + JSON.stringify({ error: "Invalid JSON body" }), + { + status: 400, + headers: { + "Content-Type": "application/json", + ...corsHeaders, + }, + } + ); + } + + const result = await handleWakeDevice(body, { + random: Math.random, + delay: (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)), + now: () => new Date(), + }); + + return new Response(JSON.stringify(result.body), { + status: result.status, + headers: { + "Content-Type": "application/json", + ...corsHeaders, + }, + }); + } catch (error: any) { + return new Response( + JSON.stringify({ + success: false, + error: error.message || "Internal server error", + }), + { + status: 500, + headers: { + "Content-Type": "application/json", + ...corsHeaders, + }, + } + ); + } +}); \ No newline at end of file diff --git a/supabase/functions/wakeDevice/logic.test.ts b/supabase/functions/wakeDevice/logic.test.ts new file mode 100644 index 00000000..e0a42f01 --- /dev/null +++ b/supabase/functions/wakeDevice/logic.test.ts @@ -0,0 +1,89 @@ +import { + handleWakeDevice, + } from "./logic.ts"; + + import { + assertEquals, + } from "https://deno.land/std@0.224.0/testing/asserts.ts"; + + + function createDeps(options?: { + randomValues?: number[]; + }) { + let delayCalledWith = 0; + + const randomValues = options?.randomValues ?? [0.1, 0.5]; + let randomIndex = 0; + + return { + deps: { + random: () => randomValues[randomIndex++], + delay: async (ms: number) => { + delayCalledWith = ms; + }, + now: () => new Date("2024-01-01T00:00:00.000Z"), + }, + getDelay: () => delayCalledWith, + }; + } + + + Deno.test("returns 400 if deviceId missing", async () => { + const { deps } = createDeps(); + + const result = await handleWakeDevice({}, deps); + + assertEquals(result.status, 400); + assertEquals(result.body.error, "deviceId is required"); + }); + + Deno.test("returns success when random < 0.95", async () => { + // First random for success = 0.1 (success) + // Second random for delay = 0.5 + const { deps, getDelay } = createDeps({ + randomValues: [0.1, 0.5], + }); + + const result = await handleWakeDevice( + { deviceId: "abc123" }, + deps + ); + + assertEquals(result.status, 200); + assertEquals(result.body.success, true); + assertEquals(result.body.deviceId, "abc123"); + assertEquals(result.body.timestamp, "2024-01-01T00:00:00.000Z"); + + // delay = floor(0.5 * 150) + 50 = 125 + assertEquals(getDelay(), 125); + assertEquals(result.body.responseTime, "125ms"); + }); + + Deno.test("returns 503 when random >= 0.95", async () => { + const { deps } = createDeps({ + randomValues: [0.99, 0.3], + }); + + const result = await handleWakeDevice( + { deviceId: "abc123" }, + deps + ); + + assertEquals(result.status, 503); + assertEquals(result.body.success, false); + assertEquals(result.body.error, "Device unreachable or timeout"); + }); + + Deno.test("delay is awaited with correct ms", async () => { + const { deps, getDelay } = createDeps({ + randomValues: [0.1, 0.0], // minimum delay + }); + + await handleWakeDevice( + { deviceId: "abc123" }, + deps + ); + + // floor(0 * 150) + 50 = 50 + assertEquals(getDelay(), 50); + }); \ No newline at end of file diff --git a/supabase/functions/wakeDevice/logic.ts b/supabase/functions/wakeDevice/logic.ts new file mode 100644 index 00000000..a82f1f56 --- /dev/null +++ b/supabase/functions/wakeDevice/logic.ts @@ -0,0 +1,55 @@ +export interface Dependencies { + random: () => number; + delay: (ms: number) => Promise; + now: () => Date; + } + + export async function handleWakeDevice( + body: any, + deps: Dependencies + ) { + const { random, delay, now } = deps; + + const deviceId = body?.deviceId; + + if (!deviceId) { + return { + status: 400, + body: { + error: "deviceId is required", + receivedBody: body, + }, + }; + } + + const success = random() < 0.95; + const responseDelay = Math.floor(random() * 150) + 50; + + await delay(responseDelay); + + const timestamp = now().toISOString(); + + if (success) { + return { + status: 200, + body: { + success: true, + deviceId, + message: "Device wake signal sent successfully", + timestamp, + responseTime: `${responseDelay}ms`, + }, + }; + } + + return { + status: 503, + body: { + success: false, + deviceId, + error: "Device unreachable or timeout", + timestamp, + responseTime: `${responseDelay}ms`, + }, + }; + } \ No newline at end of file diff --git a/test/logic/parsing/location_parser_test.dart b/test/logic/parsing/location_parser_test.dart index 38d5dafc..0d035dee 100644 --- a/test/logic/parsing/location_parser_test.dart +++ b/test/logic/parsing/location_parser_test.dart @@ -3,12 +3,15 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:clean_stream_laundry_app/widgets/map_marker.dart'; import 'package:flutter/material.dart'; import 'package:latlong2/latlong.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:geolocator/geolocator.dart'; + +class MockGeolocatorPlatform extends Mock implements GeolocatorPlatform {} void main() { group('LocationParser', () { test('parseLocations returns empty list when input is empty', () { final result = LocationParser.parseLocations([]); - expect(result, isEmpty); }); @@ -98,57 +101,413 @@ void main() { expect(result[0].point.latitude, 40.7128); expect(result[1].point.latitude, 51.5074); }); - group('LocationParser', () { - // Your existing tests... - // Add this widget test - testWidgets('parseLocations creates fully initialized Marker objects', (WidgetTester tester) async { - final locations = [ - {'Latitude': 40.7128, 'Longitude': -74.0060}, - ]; + testWidgets('parseLocations creates fully initialized Marker objects', + (WidgetTester tester) async { + final locations = [ + {'Latitude': 40.7128, 'Longitude': -74.0060}, + ]; - final result = LocationParser.parseLocations(locations); + final result = LocationParser.parseLocations(locations); - expect(result.length, 1); + expect(result.length, 1); - final marker = result[0]; - expect(marker.point.latitude, 40.7128); - expect(marker.point.longitude, -74.0060); - expect(marker.width, 50); - expect(marker.height, 50); + final marker = result[0]; + expect(marker.point.latitude, 40.7128); + expect(marker.point.longitude, -74.0060); + expect(marker.width, 50); + expect(marker.height, 50); - // Actually build the widget to ensure it's instantiated - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: marker.child, + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: marker.child, + ), ), - ), + ); + + expect(find.byType(MapMarker), findsOneWidget); + }); + + test('parseLocations constructs complete Marker with all properties', () { + final locations = [ + {'Latitude': 40.7128, 'Longitude': -74.0060}, + ]; + + final result = LocationParser.parseLocations(locations); + final marker = result[0]; + + expect(marker.point, isA()); + expect(marker.point.latitude, 40.7128); + expect(marker.point.longitude, -74.0060); + expect(marker.width, 50.0); + expect(marker.height, 50.0); + expect(marker.child, isA()); + expect(marker.point.latitude, isA()); + expect(marker.point.longitude, isA()); + }); + }); + + group('LocationParser - Geolocator Methods', () { + late MockGeolocatorPlatform mockGeolocator; + late LocationParser locationParser; + + setUp(() { + mockGeolocator = MockGeolocatorPlatform(); + locationParser = LocationParser(geolocator: mockGeolocator); + }); + + group('determinePosition', () { + test('returns position string when permission is already granted', + () async { + final mockPosition = Position( + latitude: 37.7749, + longitude: -122.4194, + timestamp: DateTime(2024, 1, 1), + accuracy: 10.0, + altitude: 0.0, + heading: 0.0, + speed: 0.0, + speedAccuracy: 0.0, + altitudeAccuracy: 0.0, + headingAccuracy: 0.0, + ); + + when(() => mockGeolocator.checkPermission()) + .thenAnswer((_) async => LocationPermission.always); + when(() => mockGeolocator.getCurrentPosition( + locationSettings: any(named: 'locationSettings'), + )).thenAnswer((_) async => mockPosition); + + final result = await locationParser.determinePosition(); + + expect(result, contains('Latitude: 37.7749')); + expect(result, contains('Longitude: -122.4194')); + verify(() => mockGeolocator.checkPermission()).called(1); + verify(() => mockGeolocator.getCurrentPosition( + locationSettings: any(named: 'locationSettings'), + )).called(1); + verifyNever(() => mockGeolocator.requestPermission()); + }); + + test('requests and grants permission when initially denied', () async { + final mockPosition = Position( + latitude: 37.7749, + longitude: -122.4194, + timestamp: DateTime(2024, 1, 1), + accuracy: 10.0, + altitude: 0.0, + heading: 0.0, + speed: 0.0, + speedAccuracy: 0.0, + altitudeAccuracy: 0.0, + headingAccuracy: 0.0, ); - expect(find.byType(MapMarker), findsOneWidget); + when(() => mockGeolocator.checkPermission()) + .thenAnswer((_) async => LocationPermission.denied); + when(() => mockGeolocator.requestPermission()) + .thenAnswer((_) async => LocationPermission.whileInUse); + when(() => mockGeolocator.getCurrentPosition( + locationSettings: any(named: 'locationSettings'), + )).thenAnswer((_) async => mockPosition); + + final result = await locationParser.determinePosition(); + + expect(result, contains('Latitude: 37.7749')); + verify(() => mockGeolocator.checkPermission()).called(1); + verify(() => mockGeolocator.requestPermission()).called(1); + verify(() => mockGeolocator.getCurrentPosition( + locationSettings: any(named: 'locationSettings'), + )).called(1); + }); + + test('handles whileInUse permission', () async { + final mockPosition = Position( + latitude: 40.7128, + longitude: -74.0060, + timestamp: DateTime(2024, 1, 1), + accuracy: 15.0, + altitude: 0.0, + heading: 0.0, + speed: 0.0, + speedAccuracy: 0.0, + altitudeAccuracy: 0.0, + headingAccuracy: 0.0, + ); + + when(() => mockGeolocator.checkPermission()) + .thenAnswer((_) async => LocationPermission.whileInUse); + when(() => mockGeolocator.getCurrentPosition( + locationSettings: any(named: 'locationSettings'), + )).thenAnswer((_) async => mockPosition); + + final result = await locationParser.determinePosition(); + + expect(result, contains('Latitude: 40.7128')); + expect(result, contains('Longitude: -74.006')); }); }); - }); - test('parseLocations constructs complete Marker with all properties', () { - final locations = [ - {'Latitude': 40.7128, 'Longitude': -74.0060}, - ]; - - final result = LocationParser.parseLocations(locations); - final marker = result[0]; - - // Verify each property to ensure the constructor was called - expect(marker.point, isA()); - expect(marker.point.latitude, 40.7128); - expect(marker.point.longitude, -74.0060); - expect(marker.width, 50.0); - expect(marker.height, 50.0); - expect(marker.child, isA()); - - // Verify toDouble() conversion happened correctly - expect(marker.point.latitude, isA()); - expect(marker.point.longitude, isA()); + group('parseCurrentLocation', () { + test('correctly parses position string into coordinate list', () async { + final mockPosition = Position( + latitude: 37.7749, + longitude: -122.4194, + timestamp: DateTime(2024, 1, 1), + accuracy: 10.0, + altitude: 0.0, + heading: 0.0, + speed: 0.0, + speedAccuracy: 0.0, + altitudeAccuracy: 0.0, + headingAccuracy: 0.0, + ); + + when(() => mockGeolocator.checkPermission()) + .thenAnswer((_) async => LocationPermission.always); + when(() => mockGeolocator.getCurrentPosition( + locationSettings: any(named: 'locationSettings'), + )).thenAnswer((_) async => mockPosition); + + final coords = await locationParser.parseCurrentLocation(); + + expect(coords.length, equals(2)); + expect(coords[0], equals(37.7749)); + expect(coords[1], equals(-122.4194)); + }); + + test('handles negative coordinates correctly', () async { + final mockPosition = Position( + latitude: -33.8688, + longitude: 151.2093, + timestamp: DateTime(2024, 1, 1), + accuracy: 10.0, + altitude: 0.0, + heading: 0.0, + speed: 0.0, + speedAccuracy: 0.0, + altitudeAccuracy: 0.0, + headingAccuracy: 0.0, + ); + + when(() => mockGeolocator.checkPermission()) + .thenAnswer((_) async => LocationPermission.always); + when(() => mockGeolocator.getCurrentPosition( + locationSettings: any(named: 'locationSettings'), + )).thenAnswer((_) async => mockPosition); + + final coords = await locationParser.parseCurrentLocation(); + + expect(coords[0], equals(-33.8688)); + expect(coords[1], equals(151.2093)); + }); + + test('returns list of doubles', () async { + final mockPosition = Position( + latitude: 51.5074, + longitude: -0.1278, + timestamp: DateTime(2024, 1, 1), + accuracy: 10.0, + altitude: 0.0, + heading: 0.0, + speed: 0.0, + speedAccuracy: 0.0, + altitudeAccuracy: 0.0, + headingAccuracy: 0.0, + ); + + when(() => mockGeolocator.checkPermission()) + .thenAnswer((_) async => LocationPermission.always); + when(() => mockGeolocator.getCurrentPosition( + locationSettings: any(named: 'locationSettings'), + )).thenAnswer((_) async => mockPosition); + + final coords = await locationParser.parseCurrentLocation(); + + expect(coords[0], isA()); + expect(coords[1], isA()); + }); + }); + + group('getNearestLocation', () { + test('returns nearest location from multiple options', () async { + final mockPosition = Position( + latitude: 37.7749, + longitude: -122.4194, + timestamp: DateTime(2024, 1, 1), + accuracy: 10.0, + altitude: 0.0, + heading: 0.0, + speed: 0.0, + speedAccuracy: 0.0, + altitudeAccuracy: 0.0, + headingAccuracy: 0.0, + ); + + when(() => mockGeolocator.checkPermission()) + .thenAnswer((_) async => LocationPermission.always); + when(() => mockGeolocator.getCurrentPosition( + locationSettings: any(named: 'locationSettings'), + )).thenAnswer((_) async => mockPosition); + + final locations = [ + { + 'Name': 'Far Location', + 'Latitude': 37.7849, + 'Longitude': -122.4094, + }, + { + 'Name': 'Nearest Location', + 'Latitude': 37.7759, + 'Longitude': -122.4184, + }, + { + 'Name': 'Distant Location', + 'Latitude': 37.8049, + 'Longitude': -122.3994, + }, + ]; + + final nearest = await locationParser.getNearestLocation(locations); + + expect(nearest, isNotNull); + expect(nearest!['Name'], equals('Nearest Location')); + expect(nearest['Latitude'], equals(37.7759)); + expect(nearest['Longitude'], equals(-122.4184)); + }); + + test('returns null when locations list is empty', () async { + final mockPosition = Position( + latitude: 37.7749, + longitude: -122.4194, + timestamp: DateTime(2024, 1, 1), + accuracy: 10.0, + altitude: 0.0, + heading: 0.0, + speed: 0.0, + speedAccuracy: 0.0, + altitudeAccuracy: 0.0, + headingAccuracy: 0.0, + ); + + when(() => mockGeolocator.checkPermission()) + .thenAnswer((_) async => LocationPermission.always); + when(() => mockGeolocator.getCurrentPosition( + locationSettings: any(named: 'locationSettings'), + )).thenAnswer((_) async => mockPosition); + + final nearest = await locationParser.getNearestLocation([]); + + expect(nearest, isNull); + }); + + test('skips locations without valid coordinates', () async { + final mockPosition = Position( + latitude: 37.7749, + longitude: -122.4194, + timestamp: DateTime(2024, 1, 1), + accuracy: 10.0, + altitude: 0.0, + heading: 0.0, + speed: 0.0, + speedAccuracy: 0.0, + altitudeAccuracy: 0.0, + headingAccuracy: 0.0, + ); + + when(() => mockGeolocator.checkPermission()) + .thenAnswer((_) async => LocationPermission.always); + when(() => mockGeolocator.getCurrentPosition( + locationSettings: any(named: 'locationSettings'), + )).thenAnswer((_) async => mockPosition); + + final locations = [ + { + 'Name': 'Invalid - No coords', + }, + { + 'Name': 'Invalid - Only Lat', + 'Latitude': 37.7759, + }, + { + 'Name': 'Valid Location', + 'Latitude': 37.7759, + 'Longitude': -122.4184, + }, + ]; + + final nearest = await locationParser.getNearestLocation(locations); + + expect(nearest, isNotNull); + expect(nearest!['Name'], equals('Valid Location')); + }); + + test('handles single location in list', () async { + final mockPosition = Position( + latitude: 37.7749, + longitude: -122.4194, + timestamp: DateTime(2024, 1, 1), + accuracy: 10.0, + altitude: 0.0, + heading: 0.0, + speed: 0.0, + speedAccuracy: 0.0, + altitudeAccuracy: 0.0, + headingAccuracy: 0.0, + ); + + when(() => mockGeolocator.checkPermission()) + .thenAnswer((_) async => LocationPermission.always); + when(() => mockGeolocator.getCurrentPosition( + locationSettings: any(named: 'locationSettings'), + )).thenAnswer((_) async => mockPosition); + + final locations = [ + { + 'Name': 'Only Location', + 'Latitude': 37.7849, + 'Longitude': -122.4094, + }, + ]; + + final nearest = await locationParser.getNearestLocation(locations); + + expect(nearest, isNotNull); + expect(nearest!['Name'], equals('Only Location')); + }); + + test('returns null when all locations have invalid coordinates', + () async { + final mockPosition = Position( + latitude: 37.7749, + longitude: -122.4194, + timestamp: DateTime(2024, 1, 1), + accuracy: 10.0, + altitude: 0.0, + heading: 0.0, + speed: 0.0, + speedAccuracy: 0.0, + altitudeAccuracy: 0.0, + headingAccuracy: 0.0, + ); + + when(() => mockGeolocator.checkPermission()) + .thenAnswer((_) async => LocationPermission.always); + when(() => mockGeolocator.getCurrentPosition( + locationSettings: any(named: 'locationSettings'), + )).thenAnswer((_) async => mockPosition); + + final locations = [ + {'Name': 'No coords'}, + {'Name': 'Only Lat', 'Latitude': 37.7759}, + {'Name': 'Only Lng', 'Longitude': -122.4184}, + ]; + + final nearest = await locationParser.getNearestLocation(locations); + + expect(nearest, isNull); + }); + }); }); -} \ No newline at end of file +} diff --git a/test/logic/parsing/mocks.dart b/test/logic/parsing/mocks.dart new file mode 100644 index 00000000..d715ebbd --- /dev/null +++ b/test/logic/parsing/mocks.dart @@ -0,0 +1,5 @@ +// test/mocks/mock_geolocator.dart +import 'package:geolocator/geolocator.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockGeolocatorPlatform extends Mock implements GeolocatorPlatform {} \ No newline at end of file diff --git a/test/logic/parsing/transaction_parser_test.dart b/test/logic/parsing/transaction_parser_test.dart index 02773b84..e269b3a2 100644 --- a/test/logic/parsing/transaction_parser_test.dart +++ b/test/logic/parsing/transaction_parser_test.dart @@ -10,7 +10,7 @@ void main(){ test("Test that transactions are parsed correctly",(){ final result = TransactionParser.formatTransaction(({"amount": 2.75, "description": "Dryer", "created_at": DateTime.now().toString()}), "transactionHistory"); - expect(result, "\$2.75 used on Dryer on ${DateFormat("MMM").format(DateTime.now()).toString()} ${DateFormat("dd").format(DateTime.now()).toString()}, ${DateFormat("y").format(DateTime.now()).toString()}"); + expect(result, "\$2.75 - Dryer on ${DateFormat("MMM").format(DateTime.now()).toString()} ${DateFormat("dd").format(DateTime.now()).toString()}, ${DateFormat("y").format(DateTime.now()).toString()}"); }); test("Test that transactions are parsed correctly if description is Loyalty Card",(){ @@ -21,7 +21,7 @@ void main(){ test("Test that it can format a list of transactions",(){ final result = TransactionParser.formatTransactionsList([{"amount": 2.75, "description": "Dryer", "created_at": DateTime.now().toString()},{"amount": 4.75, "description": "Washer", "created_at": "2025-11-12T19:23:24.781326+00:00"}], "transactionHistory"); - expect(result[0], "\$2.75 used on Dryer on ${DateFormat("MMM").format(DateTime.now()).toString()} ${DateFormat("dd").format(DateTime.now()).toString()}, ${DateFormat("y").format(DateTime.now()).toString()}"); + expect(result[0], "\$2.75 - Dryer on ${DateFormat("MMM").format(DateTime.now()).toString()} ${DateFormat("dd").format(DateTime.now()).toString()}, ${DateFormat("y").format(DateTime.now()).toString()}"); }); test("Test for monthly report",(){ diff --git a/test/logic/payment/process_payment_test.dart b/test/logic/payment/process_payment_test.dart index eba1a76f..fbe7ce24 100644 --- a/test/logic/payment/process_payment_test.dart +++ b/test/logic/payment/process_payment_test.dart @@ -1,3 +1,5 @@ +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:clean_stream_laundry_app/logic/services/payment_service.dart'; @@ -6,42 +8,52 @@ import 'package:clean_stream_laundry_app/logic/payment/process_payment.dart'; import 'package:clean_stream_laundry_app/logic/enums/payment_result_enum.dart'; import 'package:flutter_stripe/flutter_stripe.dart'; import 'package:clean_stream_laundry_app/logic/exceptions/platform_exception.dart'; +import 'package:get_it/get_it.dart'; class MockPaymentService extends Mock implements PaymentService {} class MockTransactionService extends Mock implements TransactionService {} +class MockAuthService extends Mock implements AuthService {} + +class MockProfileService extends Mock implements ProfileService {} + void main() { late MockPaymentService mockPaymentService; late MockTransactionService mockTransactionService; late PaymentProcessor paymentProcessor; + late MockAuthService mockAuthService; + late MockProfileService mockProfileService; setUp(() { mockPaymentService = MockPaymentService(); mockTransactionService = MockTransactionService(); + mockAuthService = MockAuthService(); + mockProfileService = MockProfileService(); + + final getIt = GetIt.instance; + getIt.reset(); + getIt.registerSingleton(mockPaymentService); + getIt.registerSingleton(mockTransactionService); + getIt.registerSingleton(mockAuthService); + getIt.registerSingleton(mockProfileService); + + paymentProcessor = PaymentProcessor(); - paymentProcessor = PaymentProcessor( - paymentService: mockPaymentService, - transactionService: mockTransactionService, - ); }); group('PaymentProcessor.processPayment', () { test('should complete payment and record transaction on success', () async { - // Arrange const amount = 100.0; const description = 'Test payment'; - when( - () => mockPaymentService.makePayment(amount), - ).thenAnswer((_) async => Future.value()); - when( - () => mockTransactionService.recordTransaction( - amount: amount, - description: description, - type: 'Laundry', - ), - ).thenAnswer((_) async => {}); + when(() => mockPaymentService.makePayment(amount)) + .thenAnswer((_) async => Future.value()); + when(() => mockTransactionService.recordTransaction( + amount: any(named: 'amount'), + description: any(named: 'description'), + type: any(named: 'type'), + )).thenAnswer((_) async => {}); // Act final result = await paymentProcessor.processPayment(amount, description); @@ -49,13 +61,11 @@ void main() { // Assert expect(result, PaymentResult.success); verify(() => mockPaymentService.makePayment(amount)).called(1); - verify( - () => mockTransactionService.recordTransaction( - amount: amount, - description: description, - type: 'Laundry', - ), - ).called(1); + verify(() => mockTransactionService.recordTransaction( + amount: amount, + description: description, + type: 'Laundry', + )).called(1); }); test( diff --git a/test/logic/viewmodels/loyalty_view_model_test.dart b/test/logic/viewmodels/loyalty_view_model_test.dart index a148bdd0..d5ea4845 100644 --- a/test/logic/viewmodels/loyalty_view_model_test.dart +++ b/test/logic/viewmodels/loyalty_view_model_test.dart @@ -7,6 +7,7 @@ import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; import 'package:clean_stream_laundry_app/logic/services/transaction_service.dart'; import 'package:clean_stream_laundry_app/logic/payment/process_payment.dart'; import 'mocks.dart'; +import 'package:clean_stream_laundry_app/logic/enums/payment_result_enum.dart'; void main() { late LoyaltyViewModel viewModel; @@ -46,10 +47,10 @@ void main() { // Arrange when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); when( - () => mockProfileService.getUserBalanceById('user123'), + () => mockProfileService.getUserBalanceById('user123'), ).thenAnswer((_) async => {'balance': 100.0, 'full_name': 'Jane Doe'}); when( - () => mockTransactionService.getTransactionsForUser(), + () => mockTransactionService.getTransactionsForUser(), ).thenAnswer((_) async => []); // Act @@ -71,10 +72,10 @@ void main() { // Arrange when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); when( - () => mockProfileService.getUserBalanceById('user123'), + () => mockProfileService.getUserBalanceById('user123'), ).thenThrow(Exception('Network error')); when( - () => mockTransactionService.getTransactionsForUser(), + () => mockTransactionService.getTransactionsForUser(), ).thenAnswer((_) async => []); // Act @@ -85,14 +86,30 @@ void main() { expect(viewModel.isLoading, false); }); + test('initialize should handle null userId', () async { + // Arrange + when(() => mockAuthService.getCurrentUserId).thenReturn(null); + when(() => mockTransactionService.getTransactionsForUser()) + .thenAnswer((_) async => []); + + // Act + await viewModel.initialize(); + + // Assert + expect(viewModel.errorMessage, 'User not known'); + expect(viewModel.isLoading, false); + + verifyNever(() => mockProfileService.getUserBalanceById(any())); + }); + test('should default to 0.0 balance when null', () async { // Arrange when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); when( - () => mockProfileService.getUserBalanceById('user123'), + () => mockProfileService.getUserBalanceById('user123'), ).thenAnswer((_) async => {'balance': null, 'full_name': 'Jane Doe'}); when( - () => mockTransactionService.getTransactionsForUser(), + () => mockTransactionService.getTransactionsForUser(), ).thenAnswer((_) async => []); // Act @@ -107,10 +124,10 @@ void main() { // Arrange when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); when( - () => mockProfileService.getUserBalanceById('user123'), + () => mockProfileService.getUserBalanceById('user123'), ).thenAnswer((_) async => {'balance': 100.0, 'full_name': null}); when( - () => mockTransactionService.getTransactionsForUser(), + () => mockTransactionService.getTransactionsForUser(), ).thenAnswer((_) async => []); // Act @@ -125,7 +142,7 @@ void main() { test('should toggle showPastTransactions from false to true', () async { // Arrange when( - () => mockTransactionService.getTransactionsForUser(), + () => mockTransactionService.getTransactionsForUser(), ).thenAnswer((_) async => []); expect(viewModel.showPastTransactions, false); @@ -141,7 +158,7 @@ void main() { test('should toggle showPastTransactions from true to false', () async { // Arrange when( - () => mockTransactionService.getTransactionsForUser(), + () => mockTransactionService.getTransactionsForUser(), ).thenAnswer((_) async => []); viewModel.showPastTransactions = true; @@ -158,7 +175,7 @@ void main() { test('should call transaction service', () async { // Arrange when( - () => mockTransactionService.getTransactionsForUser(), + () => mockTransactionService.getTransactionsForUser(), ).thenAnswer((_) async => []); // Act @@ -167,17 +184,96 @@ void main() { // Assert verify(() => mockTransactionService.getTransactionsForUser()).called(1); }); + + test('fetchTransactions filters out Rewards and old transactions', () async { + // Arrange + final now = DateTime.now(); + when(() => mockTransactionService.getTransactionsForUser()).thenAnswer( + (_) async => [ + { + 'created_at': now.toIso8601String(), + 'type': 'Laundry', + 'amount': 10, + 'description': 'Wash', + }, + { + 'created_at': now.toIso8601String(), + 'type': 'Rewards', + 'amount': 1, + 'description': 'Reward', + }, + { + 'created_at': + now.subtract(const Duration(days: 40)).toIso8601String(), + 'type': 'Laundry', + 'amount': 5, + 'description': 'Old wash', + }, + ], + ); + + // Act + await viewModel.toggleTransactionView(); + + // Assert + expect(viewModel.recentTransactions.length, 1); + }); }); group('loadCard', () { - // Note: This test is incomplete because processPayment is a top-level function - // See options below for how to handle this - test( - 'should update balance and fetch transactions on successful payment', - () async { - // You'll need to refactor processPayment to be testable - // See suggestions below - }, - ); + test('loadCard should update balance and fetch transactions on success', + () async { + // Arrange + when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); + + viewModel.userBalance = 20.0; + + when(() => mockPaymentProcessor.processPayment( + 10.0, + 'Loyalty Card', + )).thenAnswer((_) async => PaymentResult.success); + + when(() => mockProfileService.updateBalanceById('user123', 30)) + .thenAnswer((_) async => Future.value()); + + // Stub for checkRewards -> updateRewardsById called internally + when(() => mockProfileService.updateRewardsById(any(), any())) + .thenAnswer((_) async => Future.value()); + + when(() => mockTransactionService.getTransactionsForUser()) + .thenAnswer((_) async => []); + + // Act + final result = await viewModel.loadCard(10.0); + + // Assert + expect(result, PaymentResult.success); + expect(viewModel.userBalance, 30); + + verify(() => mockProfileService.updateBalanceById('user123', 30)) + .called(1); + verify(() => mockTransactionService.getTransactionsForUser()).called(1); + }); + + test('loadCard should not update balance on failed payment', () async { + // Arrange + when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); + + viewModel.userBalance = 20.0; + + when(() => mockPaymentProcessor.processPayment( + 10.0, + 'Loyalty Card', + )).thenAnswer((_) async => PaymentResult.failed); + + // Act + final result = await viewModel.loadCard(10.0); + + // Assert + expect(result, PaymentResult.failed); + expect(viewModel.userBalance, 20.0); + + verifyNever(() => mockProfileService.updateBalanceById(any(), any())); + }); }); -} +} \ No newline at end of file diff --git a/test/pages/edit_profile_page_test.dart b/test/pages/edit_profile_page_test.dart index 0a1b3b68..3091168a 100644 --- a/test/pages/edit_profile_page_test.dart +++ b/test/pages/edit_profile_page_test.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:clean_stream_laundry_app/pages/edit_profile_page.dart'; import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/edge_function_service.dart'; import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -12,11 +13,13 @@ import 'mocks.dart'; void main() { late MockAuthService authService; late MockProfileService profileService; + late MockEdgeFunctionService edgeFunctionService; late StreamController authController; setUp(() { authService = MockAuthService(); profileService = MockProfileService(); + edgeFunctionService = MockEdgeFunctionService(); authController = StreamController.broadcast(); final getIt = GetIt.instance; @@ -24,15 +27,16 @@ void main() { getIt.registerSingleton(authService); getIt.registerSingleton(profileService); + getIt.registerSingleton(edgeFunctionService); when(() => authService.onAuthChange) .thenAnswer((_) => authController.stream); when(() => authService.getCurrentUserId) - .thenAnswer((_) => 'user-id'); + .thenAnswer((_) => 'user-id'); when(() => authService.getCurrentUserEmail()) - .thenAnswer((_) => 'test@example.com'); + .thenAnswer((_) => 'test@example.com'); when(() => profileService.getUserNameById('user-id')) .thenAnswer((_) async => 'John Doe'); @@ -65,18 +69,45 @@ void main() { builder: (_, __) => const Scaffold(body: Text('Verify Email')), ), + GoRoute( + path: '/login', + builder: (_, __) => const Scaffold(body: Text('Login')), + ), ], ), ); } - testWidgets('loads and displays user data', (tester) async { + testWidgets('displays page title', (tester) async { await tester.pumpWidget(createWidget()); await tester.pumpAndSettle(); - expect(find.text('Current Name: John Doe'), findsOneWidget); - expect(find.text('Current Email: test@example.com'), findsOneWidget); + expect(find.text('Edit Profile'), findsOneWidget); + }); + + testWidgets('loads and displays user data in info cards', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + expect(find.text('Full Name'), findsOneWidget); + expect(find.text('Email Address'), findsOneWidget); + + expect(find.text('Current'), findsNWidgets(2)); + expect(find.text('John Doe'), findsNWidgets(2)); + expect(find.text('test@example.com'), findsNWidgets(2)); + expect(find.text('Save Changes'), findsOneWidget); + expect(find.byIcon(Icons.check_circle_outline), findsOneWidget); + }); + + testWidgets('displays danger zone section with delete account button', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + expect(find.text('Danger Zone'), findsOneWidget); + expect(find.byIcon(Icons.warning_amber_rounded), findsOneWidget); + expect(find.text('Delete Account'), findsOneWidget); + expect(find.byIcon(Icons.delete_outline), findsOneWidget); }); testWidgets('shows No Changes dialog if nothing changed', (tester) async { @@ -87,7 +118,7 @@ void main() { await tester.pumpAndSettle(); expect(find.text('No Changes'), findsOneWidget); - expect(find.text('You haven’t changed anything.'), findsOneWidget); + expect(find.text('You haven\'t changed anything.'), findsOneWidget); verifyNever(() => authService.updateUserAttributes( email: any(named: 'email'), @@ -99,27 +130,45 @@ void main() { await tester.pumpWidget(createWidget()); await tester.pumpAndSettle(); - await tester.enterText( - find.widgetWithText(TextFormField, 'Full Name'), ''); + final nameField = find.widgetWithText(TextFormField, 'New Full Name'); + await tester.enterText(nameField, ''); + await tester.tap(find.text('Save Changes')); await tester.pumpAndSettle(); - await tester.tap(find.text('Yes, Save')); + await tester.tap(find.text('Save')); await tester.pumpAndSettle(); expect(find.text('Name cannot be empty'), findsOneWidget); }); + testWidgets('validates invalid email', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + final emailField = find.widgetWithText(TextFormField, 'New Email'); + await tester.enterText(emailField, 'invalid-email'); + + await tester.tap(find.text('Save Changes')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(find.text('Please enter a valid email'), findsOneWidget); + }); + testWidgets('updates name only', (tester) async { await tester.pumpWidget(createWidget()); await tester.pumpAndSettle(); - await tester.enterText( - find.widgetWithText(TextFormField, 'Full Name'), 'Jane Smith'); + final nameField = find.widgetWithText(TextFormField, 'New Full Name'); + await tester.enterText(nameField, 'Jane Smith'); await tester.tap(find.text('Save Changes')); await tester.pumpAndSettle(); - await tester.tap(find.text('Yes, Save')); + + await tester.tap(find.text('Save')); await tester.pumpAndSettle(); verify(() => authService.updateUserAttributes( @@ -134,12 +183,13 @@ void main() { await tester.pumpWidget(createWidget()); await tester.pumpAndSettle(); - await tester.enterText( - find.widgetWithText(TextFormField, 'Email'), 'new@email.com'); + final emailField = find.widgetWithText(TextFormField, 'New Email'); + await tester.enterText(emailField, 'new@email.com'); await tester.tap(find.text('Save Changes')); await tester.pumpAndSettle(); - await tester.tap(find.text('Yes, Save')); + + await tester.tap(find.text('Save')); await tester.pumpAndSettle(); verify(() => authService.updateUserAttributes( @@ -154,14 +204,16 @@ void main() { await tester.pumpWidget(createWidget()); await tester.pumpAndSettle(); - await tester.enterText( - find.widgetWithText(TextFormField, 'Full Name'), ' Jane '); - await tester.enterText( - find.widgetWithText(TextFormField, 'Email'), ' jane@email.com '); + final nameField = find.widgetWithText(TextFormField, 'New Full Name'); + final emailField = find.widgetWithText(TextFormField, 'New Email'); + + await tester.enterText(nameField, ' Jane '); + await tester.enterText(emailField, ' jane@email.com '); await tester.tap(find.text('Save Changes')); await tester.pumpAndSettle(); - await tester.tap(find.text('Yes, Save')); + + await tester.tap(find.text('Save')); await tester.pumpAndSettle(); verify(() => authService.updateUserAttributes( @@ -179,4 +231,181 @@ void main() { expect(find.text('Settings'), findsOneWidget); }); -} + + testWidgets('shows confirmation dialog before saving changes', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + final nameField = find.widgetWithText(TextFormField, 'New Full Name'); + await tester.enterText(nameField, 'New Name'); + + await tester.tap(find.text('Save Changes')); + await tester.pumpAndSettle(); + + expect(find.text('Confirm Changes'), findsOneWidget); + expect(find.text('Are you sure you want to save these changes to your profile?'), findsOneWidget); + expect(find.text('Cancel'), findsOneWidget); + expect(find.text('Save'), findsOneWidget); + }); + + testWidgets('cancels save when user clicks cancel in confirmation', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + final nameField = find.widgetWithText(TextFormField, 'New Full Name'); + await tester.enterText(nameField, 'New Name'); + + await tester.tap(find.text('Save Changes')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + verifyNever(() => authService.updateUserAttributes( + email: any(named: 'email'), + data: any(named: 'data'), + )); + }); + + testWidgets('shows delete account confirmation dialog', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.drag(find.byType(SingleChildScrollView), const Offset(0, -500)); + await tester.pumpAndSettle(); + + final deleteButton = find.byType(OutlinedButton); + await tester.tap(deleteButton); + await tester.pumpAndSettle(); + + expect(find.text('Delete Account?'), findsOneWidget); + expect(find.text('Are you sure you want to delete your account? Any money on your loyalty card will be lost. This action cannot be undone.'), findsOneWidget); + expect(find.text('Cancel'), findsOneWidget); + expect(find.text('Delete'), findsOneWidget); + }); + + testWidgets('cancels delete when user clicks cancel', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + await tester.drag(find.byType(SingleChildScrollView), const Offset(0, -500)); + await tester.pumpAndSettle(); + + final deleteButton = find.byType(OutlinedButton); + await tester.tap(deleteButton); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + verifyNever(() => edgeFunctionService.runEdgeFunction( + name: any(named: 'name'), + body: any(named: 'body'), + )); + }); + + testWidgets('input fields have proper hint text', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + expect(find.text('Enter your full name'), findsOneWidget); + expect(find.text('Enter your email address'), findsOneWidget); + }); + + testWidgets('displays loading indicator while fetching data', (tester) async { + final completer = Completer(); + + when(() => profileService.getUserNameById('user-id')) + .thenAnswer((_) => completer.future); + + await tester.pumpWidget(createWidget()); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + completer.complete('John Doe'); + await tester.pumpAndSettle(); + + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.text('John Doe'), findsNWidgets(2)); + }); + + testWidgets('disables inputs while saving', (tester) async { + final completer = Completer(); + + when(() => authService.updateUserAttributes( + email: any(named: 'email'), + data: any(named: 'data'), + )).thenAnswer((_) => completer.future); + + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + final nameField = find.widgetWithText(TextFormField, 'New Full Name'); + await tester.enterText(nameField, 'New Name'); + + await tester.tap(find.text('Save Changes')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Save')); + await tester.pump(); + + final saveButton = tester.widget( + find.ancestor( + of: find.byType(CircularProgressIndicator), + matching: find.byType(ElevatedButton), + ).first, + ); + expect(saveButton.onPressed, isNull); + + completer.complete(); + await tester.pumpAndSettle(); + }); + + testWidgets('enforces name character limit', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + final nameField = find.widgetWithText(TextFormField, 'New Full Name'); + + const longName = 'This is a very long name that exceeds the limit'; + await tester.enterText(nameField, longName); + await tester.pump(); + + final textField = tester.widget(nameField); + final controller = textField.controller!; + + expect(controller.text.length, lessThanOrEqualTo(36)); + }); + + testWidgets('name field only allows alphanumeric and spaces', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + final nameField = find.widgetWithText(TextFormField, 'New Full Name'); + + await tester.enterText(nameField, 'Test@#\$%'); + await tester.pump(); + + final textField = tester.widget(nameField); + final controller = textField.controller!; + + expect(controller.text, 'Test'); + }); + + testWidgets('displays icon buttons in danger zone', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.warning_amber_rounded), findsOneWidget); + + expect(find.byIcon(Icons.delete_outline), findsOneWidget); + }); + + testWidgets('page is scrollable', (tester) async { + await tester.pumpWidget(createWidget()); + await tester.pumpAndSettle(); + + expect(find.byType(SingleChildScrollView), findsOneWidget); + }); +} \ No newline at end of file diff --git a/test/pages/home_page_test.dart b/test/pages/home_page_test.dart index dd0c7abc..88a0bee2 100644 --- a/test/pages/home_page_test.dart +++ b/test/pages/home_page_test.dart @@ -81,13 +81,6 @@ void main() { .thenAnswer((_) async => idleDryers); } - Future selectLocation(WidgetTester tester, String address) async { - await tester.tap(find.byType(DropdownButton)); - await tester.pumpAndSettle(); - await tester.tap(find.text(address).last); - await tester.pumpAndSettle(); - } - group('HomePage Widget Tests', () { test('should create HomePageState', () { const homePage = HomePage(); @@ -129,21 +122,19 @@ void main() { await tester.pumpWidget(createWidgetUnderTest()); await tester.pumpAndSettle(); - await tester.pumpAndSettle(const Duration(seconds: 1)); - - final dropdownFinder = find.descendant( - of: find.byType(DropdownButtonHideUnderline), - matching: find.byType(DropdownButton), - ); + // Find and tap the button that opens the BottomSheet + final openSheetButton = find.text('Select Location'); + expect(openSheetButton, findsOneWidget); - expect(dropdownFinder, findsOneWidget); + await tester.tap(openSheetButton); + await tester.pumpAndSettle(); - final dropdown = tester.widget>(dropdownFinder); - expect(dropdown.items, isNotNull); - expect(dropdown.items!.length, equals(2)); + // Now the BottomSheet should be visible with your location items + final location1 = find.text('123 Main St'); + final location2 = find.text('456 Oak Ave'); - expect(dropdown.items![0].value, equals('123 Main St')); - expect(dropdown.items![1].value, equals('456 Oak Ave')); + expect(location1, findsOneWidget); + expect(location2, findsOneWidget); }); testWidgets('should restore last selected location from storage', (tester) async { @@ -177,6 +168,141 @@ void main() { }); }); + + group('Nearest Location Button', () { + testWidgets('should find and select nearest location when button is tapped', (tester) async { + final testLocations = [ + { + "id": 1, + "Address": "123 Main St", + "Latitude": 40.0, + "Longitude": -86.0, + }, + { + "id": 2, + "Address": "456 Oak Ave", + "Latitude": 40.5, + "Longitude": -86.5, + }, + ]; + + mockLocations(testLocations); + mockMachineCounts('1'); + + await tester.pumpWidget(createWidgetUnderTest()); + await tester.pumpAndSettle(); + + final nearestLocationButton = find.ancestor( + of: find.text('Nearest Location'), + matching: find.byType(InkWell), + ); + expect(nearestLocationButton, findsOneWidget); + + await tester.tap(nearestLocationButton); + await tester.pumpAndSettle(); + + verify(() => mockLocationService.getLocations()).called(greaterThan(1)); + }); + + testWidgets('should display nearest location button with correct styling', (tester) async { + mockLocations([{"id": 1, "Address": "123 Main St"}]); + await tester.pumpWidget(createWidgetUnderTest()); + await tester.pumpAndSettle(); + + final button = find.ancestor( + of: find.text('Nearest Location'), + matching: find.byType(InkWell), + ); + expect(button, findsOneWidget); + + final inkWell = tester.widget(button); + expect(inkWell.onTap, isNotNull); + + expect(find.text('Nearest Location'), findsOneWidget); + }); + + testWidgets('should update selected location after finding nearest', (tester) async { + final testLocations = [ + { + "id": 1, + "Address": "123 Main St", + "Latitude": 40.0, + "Longitude": -86.0, + }, + { + "id": 2, + "Address": "456 Oak Ave", + "Latitude": 40.5, + "Longitude": -86.5, + }, + ]; + + mockLocations(testLocations); + mockMachineCounts('1'); + mockMachineCounts('2'); + + await tester.pumpWidget(createWidgetUnderTest()); + await tester.pumpAndSettle(); + + expect(find.text('Select Location'), findsOneWidget); + + final nearestLocationButton = find.ancestor( + of: find.text('Nearest Location'), + matching: find.byType(InkWell), + ); + await tester.tap(nearestLocationButton); + await tester.pumpAndSettle(); + + }); + + testWidgets('should save selected location to storage', (tester) async { + final testLocations = [ + { + "id": 1, + "Address": "123 Main St", + "Latitude": 40.0, + "Longitude": -86.0, + }, + ]; + + mockLocations(testLocations); + mockMachineCounts('1'); + + await tester.pumpWidget(createWidgetUnderTest()); + await tester.pumpAndSettle(); + + final nearestLocationButton = find.ancestor( + of: find.text('Nearest Location'), + matching: find.byType(InkWell), + ); + await tester.tap(nearestLocationButton); + await tester.pumpAndSettle(); + }); + }); + + group("Tests navigation button", (){ + + testWidgets('Tests that icon button is visible', (tester) async { + + final testLocations = [ + { + "id": 1, + "Address": "123 Main St", + "Latitude": 40.0, + "Longitude": -86.0, + }, + ]; + + mockLocations(testLocations); + mockMachineCounts('1'); + + await tester.pumpWidget(createWidgetUnderTest()); + await tester.pumpAndSettle(); + + expect(find.byType(IconButton), findsOneWidget); + }); + }); + }); } \ No newline at end of file diff --git a/test/pages/loading_page_test.dart b/test/pages/loading_page_test.dart index e9c227eb..3bee029f 100644 --- a/test/pages/loading_page_test.dart +++ b/test/pages/loading_page_test.dart @@ -251,7 +251,7 @@ void main() { name: any(named: 'name'), )).thenAnswer((_) async {}); - when(() => mockAuthService.handleOAuthRedirect(any())) + when(() => mockAuthService.getSessionFromURI(any())) .thenAnswer((_) async {}); await tester.pumpWidget(createTestWidget(LoadingPage())); diff --git a/test/pages/login_page_test.dart b/test/pages/login_page_test.dart index 1b9eceb1..d9c6469d 100644 --- a/test/pages/login_page_test.dart +++ b/test/pages/login_page_test.dart @@ -389,7 +389,7 @@ void main() { await tester.pumpAndSettle(); when( - () => mockAuthService.handleOAuthRedirect(any()), + () => mockAuthService.getSessionFromURI(any()), ).thenAnswer((_) async {}); when( () => mockAuthService.isLoggedIn(), @@ -414,7 +414,7 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Home Page'), findsOneWidget); - verify(() => mockAuthService.handleOAuthRedirect(any())).called(1); + verify(() => mockAuthService.getSessionFromURI(any())).called(1); verify(() => mockAuthService.isLoggedIn()).called(1); verify(() => mockAuthService.getCurrentUser()).called(1); verify( diff --git a/test/pages/loyalty_card_page_test.dart b/test/pages/loyalty_card_page_test.dart index 957ae99b..255f39af 100644 --- a/test/pages/loyalty_card_page_test.dart +++ b/test/pages/loyalty_card_page_test.dart @@ -12,6 +12,22 @@ import 'mocks.dart'; void main() { late MockLoyaltyViewModel mockViewModel; + const singleTransaction = 'Test transaction'; + const firstTransaction = 'Test transaction 1'; + const secondTransaction = 'Test transaction 2'; + const thirdTransaction = 'Test transaction 3'; + const transactionHistory = [ + firstTransaction, + secondTransaction, + thirdTransaction, + ]; + + Finder findTransactionScrollable() { + return find.descendant( + of: find.byType(ListView), + matching: find.byType(Scrollable), + ); + } setUpAll(() { // Register fallback values for mocktail @@ -129,7 +145,7 @@ void main() { await tester.pumpWidget(createTestWidget(const LoyaltyPage())); await tester.pump(); - expect(find.text('Current Balance: \$42.75'), findsOneWidget); + expect(find.text('Loyalty Balance: \$42.75'), findsOneWidget); }); testWidgets('should display balance with two decimal places', ( @@ -140,7 +156,7 @@ void main() { await tester.pumpWidget(createTestWidget(const LoyaltyPage())); await tester.pump(); - expect(find.text('Current Balance: \$100.00'), findsOneWidget); + expect(find.text('Loyalty Balance: \$100.00'), findsOneWidget); }); testWidgets('should display default balance when userBalance is null', ( @@ -151,7 +167,7 @@ void main() { await tester.pumpWidget(createTestWidget(const LoyaltyPage())); await tester.pump(); - expect(find.text('Current Balance: \$0.00'), findsOneWidget); + expect(find.text('Loyalty Balance: \$0.00'), findsOneWidget); }); testWidgets('should display Load card button with correct styling', ( @@ -168,13 +184,6 @@ void main() { expect(button.onPressed, isNotNull); }); - - testWidgets('should have scrollable content', (tester) async { - await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); - - expect(find.byType(SingleChildScrollView), findsOneWidget); - }); }); group('Transactions Display', () { @@ -195,7 +204,7 @@ void main() { ) async { when( () => mockViewModel.recentTransactions, - ).thenReturn(['Transaction 1']); + ).thenReturn([singleTransaction]); await tester.pumpWidget(createTestWidget(const LoyaltyPage())); await tester.pump(); @@ -204,18 +213,23 @@ void main() { }); testWidgets('should display all transactions in list', (tester) async { - when(() => mockViewModel.recentTransactions).thenReturn([ - 'Loaded \$10.00 on 01/10/2025', - 'Used \$2.50 on 01/09/2025', - 'Loaded \$25.00 on 01/08/2025', - ]); + when( + () => mockViewModel.recentTransactions, + ).thenReturn(transactionHistory); await tester.pumpWidget(createTestWidget(const LoyaltyPage())); - await tester.pump(); + await tester.pumpAndSettle(); + + expect(find.text(firstTransaction, skipOffstage: false), findsOneWidget); - expect(find.text('Loaded \$10.00 on 01/10/2025'), findsOneWidget); - expect(find.text('Used \$2.50 on 01/09/2025'), findsOneWidget); - expect(find.text('Loaded \$25.00 on 01/08/2025'), findsOneWidget); + await tester.scrollUntilVisible( + find.text(secondTransaction), + 100, + scrollable: findTransactionScrollable(), + ); + expect(find.text(secondTransaction), findsOneWidget); + + expect(find.text(thirdTransaction, skipOffstage: false), findsOneWidget); }); testWidgets( @@ -223,7 +237,7 @@ void main() { (tester) async { when( () => mockViewModel.recentTransactions, - ).thenReturn(['Transaction 1']); + ).thenReturn([singleTransaction]); when(() => mockViewModel.showPastTransactions).thenReturn(false); await tester.pumpWidget(createTestWidget(const LoyaltyPage())); @@ -239,7 +253,7 @@ void main() { (tester) async { when( () => mockViewModel.recentTransactions, - ).thenReturn(['Transaction 1']); + ).thenReturn([singleTransaction]); when(() => mockViewModel.showPastTransactions).thenReturn(true); await tester.pumpWidget(createTestWidget(const LoyaltyPage())); @@ -255,7 +269,7 @@ void main() { ) async { when( () => mockViewModel.recentTransactions, - ).thenReturn(['Transaction 1']); + ).thenReturn([singleTransaction]); when(() => mockViewModel.showPastTransactions).thenReturn(false); await tester.pumpWidget(createTestWidget(const LoyaltyPage())); @@ -272,7 +286,7 @@ void main() { ) async { when( () => mockViewModel.recentTransactions, - ).thenReturn(['Transaction 1']); + ).thenReturn([singleTransaction]); when(() => mockViewModel.showPastTransactions).thenReturn(true); await tester.pumpWidget(createTestWidget(const LoyaltyPage())); @@ -289,13 +303,16 @@ void main() { ) async { when( () => mockViewModel.recentTransactions, - ).thenReturn(['Transaction 1']); + ).thenReturn([singleTransaction]); await tester.pumpWidget(createTestWidget(const LoyaltyPage())); await tester.pump(); - expect(find.byType(Card), findsWidgets); - expect(find.byIcon(Icons.receipt_long), findsOneWidget); + expect(find.byType(Card, skipOffstage: false), findsWidgets); + expect( + find.byIcon(Icons.receipt_long, skipOffstage: false), + findsOneWidget, + ); }); }); @@ -792,7 +809,7 @@ void main() { await tester.pumpWidget(createTestWidget(const LoyaltyPage())); await tester.pump(); - expect(find.text('Current Balance: \$0.00'), findsOneWidget); + expect(find.text('Loyalty Balance: \$0.00'), findsOneWidget); }); testWidgets('should handle empty transaction list', (tester) async { @@ -811,7 +828,7 @@ void main() { await tester.pumpWidget(createTestWidget(const LoyaltyPage())); await tester.pump(); - expect(find.text('Current Balance: \$9999.99'), findsOneWidget); + expect(find.text('Loyalty Balance: \$9999.99'), findsOneWidget); }); testWidgets('should handle zero balance', (tester) async { @@ -820,7 +837,52 @@ void main() { await tester.pumpWidget(createTestWidget(const LoyaltyPage())); await tester.pump(); - expect(find.text('Current Balance: \$0.00'), findsOneWidget); + expect(find.text('Loyalty Balance: \$0.00'), findsOneWidget); + }); + }); + group('Reward Info Dialog', () { + testWidgets('should display info button next to reward text', ( + tester, + ) async { + when(() => mockViewModel.userReward).thenReturn(5.0); + + await tester.pumpWidget(createTestWidget(const LoyaltyPage())); + await tester.pump(); + + expect(find.byIcon(Icons.info_outline), findsOneWidget); + }); + + testWidgets('should open reward info dialog when info button is tapped', ( + tester, + ) async { + await tester.pumpWidget(createTestWidget(const LoyaltyPage())); + await tester.pump(); + + await tester.tap(find.byIcon(Icons.info_outline)); + await tester.pumpAndSettle(); + + expect(find.text('Rewards program'), findsOneWidget); + expect( + find.text( + 'For every \$20 you spend, you get an extra \$5 automatically added to your loyalty balance.', + ), + findsOneWidget, + ); + }); + + testWidgets('should close reward info dialog when Got it is tapped', ( + tester, + ) async { + await tester.pumpWidget(createTestWidget(const LoyaltyPage())); + await tester.pump(); + + await tester.tap(find.byIcon(Icons.info_outline)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Got it')); + await tester.pumpAndSettle(); + + expect(find.text('Rewards program'), findsNothing); }); }); } diff --git a/test/pages/mocks.dart b/test/pages/mocks.dart index afef57cd..a6dee910 100644 --- a/test/pages/mocks.dart +++ b/test/pages/mocks.dart @@ -14,6 +14,7 @@ import 'package:clean_stream_laundry_app/logic/services/edge_function_service.da import 'package:clean_stream_laundry_app/services/notification_service.dart'; import 'package:clean_stream_laundry_app/logic/payment/process_payment.dart'; import 'package:clean_stream_laundry_app/logic/viewmodels/loyalty_view_model.dart'; +import 'package:clean_stream_laundry_app/services/kisi/door_unlocker.dart'; class MockAuthService extends Mock implements AuthService {} @@ -23,6 +24,8 @@ class MockLocationService extends Mock implements LocationService {} class MockMachineService extends Mock implements MachineService {} +class MockDoorUnlocker extends Mock implements DoorUnlocker {} + class MockThemeManager extends Mock implements ThemeManager {} class MockProfileService extends Mock implements ProfileService {} diff --git a/test/pages/monthly_transaction_history_test.dart b/test/pages/monthly_transaction_history_test.dart index 465adb12..0bd75d28 100644 --- a/test/pages/monthly_transaction_history_test.dart +++ b/test/pages/monthly_transaction_history_test.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; import 'mocks.dart'; void main() { @@ -27,9 +28,7 @@ void main() { getIt.registerSingleton(mockTransactionService); }); - tearDown(() { - GetIt.instance.reset(); - }); + tearDown(() => GetIt.instance.reset()); /// Helper function to create a transaction in the past month Map createTransaction({ @@ -46,7 +45,7 @@ void main() { }; } - Widget createTestWidget(List> transactions) { + Widget createWidgetUnderTest(List> transactions) { router = GoRouter( routes: [ GoRoute( @@ -59,24 +58,32 @@ void main() { return MaterialApp.router(routerConfig: router); } + Future selectYearFilter(WidgetTester tester, int year) async { + await tester.tap(find.byKey(const ValueKey('year-filter-button'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(ValueKey('year-option-$year'))); + await tester.pumpAndSettle(); + } + group('MonthlyTransactionHistory Widget Tests', () { - testWidgets('renders AppBar title even when transactions empty', - (WidgetTester tester) async { - await tester.pumpWidget(createTestWidget([])); - await tester.pumpAndSettle(); + testWidgets('renders AppBar title even when transactions empty', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(createWidgetUnderTest([])); + await tester.pumpAndSettle(); - expect(find.text('Monthly Transaction History'), findsOneWidget); - }); + expect(find.text('Monthly Transaction History'), findsOneWidget); + }); testWidgets('renders AppBar with back button', (WidgetTester tester) async { - await tester.pumpWidget(createTestWidget([])); + await tester.pumpWidget(createWidgetUnderTest([])); await tester.pumpAndSettle(); expect(find.byIcon(Icons.arrow_back), findsOneWidget); }); testWidgets('back button pops the route', (WidgetTester tester) async { - await tester.pumpWidget(createTestWidget([])); + await tester.pumpWidget(createWidgetUnderTest([])); await tester.pumpAndSettle(); await tester.tap(find.byIcon(Icons.arrow_back)); @@ -85,41 +92,65 @@ void main() { expect(tester.takeException(), isNotNull); }); - testWidgets('displays no cards when transactions are empty', - (WidgetTester tester) async { - await tester.pumpWidget(createTestWidget([])); - await tester.pumpAndSettle(); + testWidgets('displays no cards when transactions are empty', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(createWidgetUnderTest([])); + await tester.pumpAndSettle(); - expect(find.byType(Card), findsNothing); - }); + expect(find.byType(Card), findsNothing); + }); - testWidgets('displays card for month with transactions', - (WidgetTester tester) async { - final transactions = [ - createTransaction( - monthsAgo: 1, - description: 'Washer #5', - amount: 2.50, - ), - ]; + testWidgets('displays card for month with transactions', ( + WidgetTester tester, + ) async { + final transactions = [ + createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 2.50), + ]; + + await tester.pumpWidget(createWidgetUnderTest(transactions)); + await tester.pumpAndSettle(); - await tester.pumpWidget(createTestWidget(transactions)); - await tester.pumpAndSettle(); + final transactionYear = DateTime.parse( + transactions.first['created_at'] as String, + ).year; + await selectYearFilter(tester, transactionYear); - expect(find.byType(Card), findsOneWidget); - }); + expect(find.byType(Card), findsOneWidget); + }); testWidgets('displays correct monthly total', (WidgetTester tester) async { final transactions = [ + createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 2.50), + createTransaction(monthsAgo: 1, description: 'Dryer #3', amount: 1.75), createTransaction( monthsAgo: 1, - description: 'Washer #5', - amount: 2.50, + description: 'loyalty card', + amount: 10.00, + ), + ]; + + await tester.pumpWidget(createWidgetUnderTest(transactions)); + await tester.pumpAndSettle(); + + expect(find.text('\$14.25'), findsOneWidget); + }); + + testWidgets('displays all transaction categories', ( + WidgetTester tester, + ) async { + final transactions = [ + createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 2.50), + createTransaction( + monthsAgo: 1, + description: 'Loyalty Payment on Washer #3', + amount: 2.00, ), + createTransaction(monthsAgo: 1, description: 'Dryer #2', amount: 1.75), createTransaction( monthsAgo: 1, - description: 'Dryer #3', - amount: 1.75, + description: 'Loyalty Payment on Dryer #1', + amount: 1.50, ), createTransaction( monthsAgo: 1, @@ -128,426 +159,476 @@ void main() { ), ]; - await tester.pumpWidget(createTestWidget(transactions)); + await tester.pumpWidget(createWidgetUnderTest(transactions)); await tester.pumpAndSettle(); - expect(find.text('\$14.25'), findsOneWidget); + expect(find.text('Direct Washer Payments'), findsOneWidget); + expect(find.text('Loyalty Washer Payments'), findsOneWidget); + expect(find.text('Direct Dryer Payments'), findsOneWidget); + expect(find.text('Loyalty Dryer Payments'), findsOneWidget); + expect(find.text('Loyalty Card Loads'), findsOneWidget); }); - testWidgets('displays all transaction categories', - (WidgetTester tester) async { - final transactions = [ - createTransaction( - monthsAgo: 1, - description: 'Washer #5', - amount: 2.50, - ), - createTransaction( - monthsAgo: 1, - description: 'Loyalty Payment on Washer #3', - amount: 2.00, - ), - createTransaction( - monthsAgo: 1, - description: 'Dryer #2', - amount: 1.75, - ), - createTransaction( - monthsAgo: 1, - description: 'Loyalty Payment on Dryer #1', - amount: 1.50, - ), - createTransaction( - monthsAgo: 1, - description: 'loyalty card', - amount: 10.00, - ), - ]; - - await tester.pumpWidget(createTestWidget(transactions)); - await tester.pumpAndSettle(); - - expect(find.text('Direct Washer Payments'), findsOneWidget); - expect(find.text('Loyalty Washer Payments'), findsOneWidget); - expect(find.text('Direct Dryer Payments'), findsOneWidget); - expect(find.text('Loyalty Dryer Payments'), findsOneWidget); - expect(find.text('Loyalty Card Loads'), findsOneWidget); - }); - - testWidgets('displays correct amounts for each category', - (WidgetTester tester) async { - final transactions = [ - createTransaction( - monthsAgo: 1, - description: 'Washer #5', - amount: 2.50, - ), - createTransaction( - monthsAgo: 1, - description: 'Washer #3', - amount: 3.00, - ), - ]; - - await tester.pumpWidget(createTestWidget(transactions)); - await tester.pumpAndSettle(); - - expect(find.text('\$5.50'), findsWidgets); - }); - - testWidgets('sorts months in descending order', - (WidgetTester tester) async { - final now = DateTime.now(); - final transactions = [ - createTransaction( - monthsAgo: 3, - description: 'Washer #5', - amount: 2.50, - ), - createTransaction( - monthsAgo: 1, - description: 'Washer #5', - amount: 3.00, - ), - createTransaction( - monthsAgo: 2, - description: 'Washer #5', - amount: 2.75, - ), - ]; - - await tester.pumpWidget(createTestWidget(transactions)); - await tester.pumpAndSettle(); - - final cardFinder = find.byType(Card); - expect(cardFinder, findsNWidgets(3)); - - final firstCard = cardFinder.first; - final firstCardTexts = find.descendant( - of: firstCard, - matching: find.byType(Text), - ); - expect(firstCardTexts, findsAtLeastNWidgets(1)); - }); + testWidgets('displays correct amounts for each category', ( + WidgetTester tester, + ) async { + final transactions = [ + createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 2.50), + createTransaction(monthsAgo: 1, description: 'Washer #3', amount: 3.00), + ]; + + await tester.pumpWidget(createWidgetUnderTest(transactions)); + await tester.pumpAndSettle(); + + expect(find.text('\$5.50'), findsWidgets); + }); + + testWidgets('sorts months in descending order', ( + WidgetTester tester, + ) async { + final transactions = [ + createTransaction(monthsAgo: 3, description: 'Washer #5', amount: 2.50), + createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 3.00), + createTransaction(monthsAgo: 2, description: 'Washer #5', amount: 2.75), + ]; + + await tester.pumpWidget(createWidgetUnderTest(transactions)); + await tester.pumpAndSettle(); + + final cardFinder = find.byType(Card); + expect(cardFinder, findsAtLeastNWidgets(2)); + + final firstCard = cardFinder.first; + final firstCardTexts = find.descendant( + of: firstCard, + matching: find.byType(Text), + ); + expect(firstCardTexts, findsAtLeastNWidgets(1)); + + final expectedMostRecent = DateFormat( + 'MMM yyyy', + ).format(DateTime(DateTime.now().year, DateTime.now().month - 1, 1)); + expect( + find.descendant(of: firstCard, matching: find.text(expectedMostRecent)), + findsOneWidget, + ); + }); testWidgets('displays scrollbar', (WidgetTester tester) async { + final transactions = [ + createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 2.50), + ]; + + await tester.pumpWidget(createWidgetUnderTest(transactions)); + await tester.pumpAndSettle(); + + expect(find.byType(Scrollbar), findsOneWidget); + }); + + testWidgets('displays ListView with proper padding', ( + WidgetTester tester, + ) async { + final transactions = [ + createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 2.50), + ]; + + await tester.pumpWidget(createWidgetUnderTest(transactions)); + await tester.pumpAndSettle(); + + final listView = tester.widget(find.byType(ListView)); + expect(listView.padding, const EdgeInsets.all(16)); + }); + + testWidgets('displays multiple months correctly', ( + WidgetTester tester, + ) async { + final transactions = [ + createTransaction(monthsAgo: 3, description: 'Washer #5', amount: 2.50), + createTransaction(monthsAgo: 2, description: 'Washer #5', amount: 3.00), + createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 3.50), + ]; + + await tester.pumpWidget(createWidgetUnderTest(transactions)); + await tester.pumpAndSettle(); + + expect(find.byType(Card), findsAtLeastNWidgets(2)); + }); + + testWidgets('displays divider between month and transaction details', ( + WidgetTester tester, + ) async { + final transactions = [ + createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 2.50), + ]; + + await tester.pumpWidget(createWidgetUnderTest(transactions)); + await tester.pumpAndSettle(); + + expect(find.byType(Divider), findsOneWidget); + }); + + testWidgets('displays zero amounts when no transactions of that type', ( + WidgetTester tester, + ) async { + final transactions = [ + createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 2.50), + ]; + + await tester.pumpWidget(createWidgetUnderTest(transactions)); + await tester.pumpAndSettle(); + + final zeroAmountFinder = find.text('\$0.00'); + expect(zeroAmountFinder, findsWidgets); + }); + + testWidgets('card has proper margin', (WidgetTester tester) async { + final transactions = [ + createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 2.50), + ]; + + await tester.pumpWidget(createWidgetUnderTest(transactions)); + await tester.pumpAndSettle(); + + final card = tester.widget(find.byType(Card)); + expect(card.margin, const EdgeInsets.only(bottom: 16)); + }); + + testWidgets('handles loyalty washer payments correctly', ( + WidgetTester tester, + ) async { final transactions = [ createTransaction( monthsAgo: 1, - description: 'Washer #5', + description: 'Loyalty Payment on Washer #5', amount: 2.50, ), + createTransaction( + monthsAgo: 1, + description: 'loyalty payment on washer #3', + amount: 3.00, + ), ]; - await tester.pumpWidget(createTestWidget(transactions)); + await tester.pumpWidget(createWidgetUnderTest(transactions)); await tester.pumpAndSettle(); - expect(find.byType(Scrollbar), findsOneWidget); + expect(find.text('\$5.50'), findsWidgets); }); - testWidgets('displays ListView with proper padding', - (WidgetTester tester) async { - final transactions = [ - createTransaction( - monthsAgo: 1, - description: 'Washer #5', - amount: 2.50, - ), - ]; - - await tester.pumpWidget(createTestWidget(transactions)); - await tester.pumpAndSettle(); - - final listView = tester.widget(find.byType(ListView)); - expect(listView.padding, const EdgeInsets.all(16)); - }); - - testWidgets('displays multiple months correctly', - (WidgetTester tester) async { - final transactions = [ - createTransaction( - monthsAgo: 3, - description: 'Washer #5', - amount: 2.50, - ), - createTransaction( - monthsAgo: 2, - description: 'Washer #5', - amount: 3.00, - ), - createTransaction( - monthsAgo: 1, - description: 'Washer #5', - amount: 3.50, - ), - ]; - - await tester.pumpWidget(createTestWidget(transactions)); - await tester.pumpAndSettle(); - - expect(find.byType(Card), findsNWidgets(3)); - }); - - testWidgets('displays divider between month and transaction details', - (WidgetTester tester) async { - final transactions = [ - createTransaction( - monthsAgo: 1, - description: 'Washer #5', - amount: 2.50, - ), - ]; - - await tester.pumpWidget(createTestWidget(transactions)); - await tester.pumpAndSettle(); - - expect(find.byType(Divider), findsOneWidget); - }); - - testWidgets('displays zero amounts when no transactions of that type', - (WidgetTester tester) async { - final transactions = [ - createTransaction( - monthsAgo: 1, - description: 'Washer #5', - amount: 2.50, - ), - ]; - - await tester.pumpWidget(createTestWidget(transactions)); - await tester.pumpAndSettle(); - - final zeroAmountFinder = find.text('\$0.00'); - expect(zeroAmountFinder, findsWidgets); - }); + testWidgets('handles loyalty dryer payments correctly', ( + WidgetTester tester, + ) async { + final transactions = [ + createTransaction( + monthsAgo: 1, + description: 'Loyalty Payment on Dryer #2', + amount: 1.50, + ), + createTransaction( + monthsAgo: 1, + description: 'loyalty payment on dryer #1', + amount: 1.25, + ), + ]; + + await tester.pumpWidget(createWidgetUnderTest(transactions)); + await tester.pumpAndSettle(); - testWidgets('card has proper margin', (WidgetTester tester) async { + expect(find.text('\$2.75'), findsWidgets); + }); + + testWidgets('handles direct dryer payments correctly', ( + WidgetTester tester, + ) async { final transactions = [ + createTransaction(monthsAgo: 1, description: 'Dryer #2', amount: 1.50), + createTransaction(monthsAgo: 1, description: 'DRYER #1', amount: 1.25), + ]; + + await tester.pumpWidget(createWidgetUnderTest(transactions)); + await tester.pumpAndSettle(); + + expect(find.text('\$2.75'), findsWidgets); + }); + + testWidgets('handles loyalty card loads correctly', ( + WidgetTester tester, + ) async { + final transactions = [ + createTransaction( + monthsAgo: 1, + description: 'loyalty card', + amount: 10.00, + ), createTransaction( monthsAgo: 1, + description: 'loyalty card', + amount: 20.00, + ), + ]; + + await tester.pumpWidget(createWidgetUnderTest(transactions)); + await tester.pumpAndSettle(); + + expect(find.text('\$30.00'), findsWidgets); + }); + + testWidgets('displays formatted decimal amounts correctly', ( + WidgetTester tester, + ) async { + final transactions = [ + createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 2.5), + createTransaction(monthsAgo: 1, description: 'Dryer #3', amount: 1.76), + ]; + + await tester.pumpWidget(createWidgetUnderTest(transactions)); + await tester.pumpAndSettle(); + + expect(find.text('\$2.50'), findsWidgets); + expect(find.text('\$1.76'), findsWidgets); + }); + + testWidgets('multiple transactions in same month aggregate correctly', ( + WidgetTester tester, + ) async { + final transactions = [ + createTransaction(monthsAgo: 1, description: 'Washer #1', amount: 2.50), + createTransaction(monthsAgo: 1, description: 'Washer #2', amount: 3.00), + createTransaction(monthsAgo: 1, description: 'Washer #3', amount: 2.75), + ]; + + await tester.pumpWidget(createWidgetUnderTest(transactions)); + await tester.pumpAndSettle(); + + expect(find.text('\$8.25'), findsWidgets); + expect(find.byType(Card), findsOneWidget); + }); + + testWidgets('ignores current month transactions', ( + WidgetTester tester, + ) async { + final now = DateTime.now(); + final transactions = [ + { + 'created_at': DateTime(now.year, now.month, 15).toIso8601String(), + 'description': 'Washer #5', + 'amount': 2.50, + }, + createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 3.00), + ]; + + await tester.pumpWidget(createWidgetUnderTest(transactions)); + await tester.pumpAndSettle(); + + final transactionYear = DateTime.parse( + transactions.first['created_at'] as String, + ).year; + await selectYearFilter(tester, transactionYear); + + expect(find.byType(Card), findsOneWidget); + expect(find.text('\$3.00'), findsExactly(2)); + }); + + testWidgets('handles transactions from exactly 11 months ago', ( + WidgetTester tester, + ) async { + final transactions = [ + createTransaction( + monthsAgo: 11, description: 'Washer #5', amount: 2.50, ), ]; - await tester.pumpWidget(createTestWidget(transactions)); + await tester.pumpWidget(createWidgetUnderTest(transactions)); await tester.pumpAndSettle(); - final card = tester.widget(find.byType(Card)); - expect(card.margin, const EdgeInsets.only(bottom: 16)); + final transactionYear = DateTime.parse( + transactions.first['created_at'] as String, + ).year; + await selectYearFilter(tester, transactionYear); + + expect(find.byType(Card), findsOneWidget); }); - testWidgets('handles loyalty washer payments correctly', - (WidgetTester tester) async { - final transactions = [ - createTransaction( - monthsAgo: 1, - description: 'Loyalty Payment on Washer #5', - amount: 2.50, - ), - createTransaction( - monthsAgo: 1, - description: 'loyalty payment on washer #3', - amount: 3.00, - ), - ]; - - await tester.pumpWidget(createTestWidget(transactions)); - await tester.pumpAndSettle(); - - expect(find.text('\$5.50'), findsWidgets); - }); - - testWidgets('handles loyalty dryer payments correctly', - (WidgetTester tester) async { - final transactions = [ - createTransaction( - monthsAgo: 1, - description: 'Loyalty Payment on Dryer #2', - amount: 1.50, - ), - createTransaction( - monthsAgo: 1, - description: 'loyalty payment on dryer #1', - amount: 1.25, - ), - ]; - - await tester.pumpWidget(createTestWidget(transactions)); - await tester.pumpAndSettle(); - - expect(find.text('\$2.75'), findsWidgets); - }); - - testWidgets('handles direct dryer payments correctly', - (WidgetTester tester) async { - final transactions = [ - createTransaction( - monthsAgo: 1, - description: 'Dryer #2', - amount: 1.50, - ), - createTransaction( - monthsAgo: 1, - description: 'DRYER #1', - amount: 1.25, - ), - ]; - - await tester.pumpWidget(createTestWidget(transactions)); - await tester.pumpAndSettle(); - - expect(find.text('\$2.75'), findsWidgets); - }); - - testWidgets('handles loyalty card loads correctly', - (WidgetTester tester) async { - final transactions = [ - createTransaction( - monthsAgo: 1, - description: 'loyalty card', - amount: 10.00, - ), - createTransaction( - monthsAgo: 1, - description: 'loyalty card', - amount: 20.00, - ), - ]; - - await tester.pumpWidget(createTestWidget(transactions)); - await tester.pumpAndSettle(); - - expect(find.text('\$30.00'), findsWidgets); - }); - - testWidgets('displays formatted decimal amounts correctly', - (WidgetTester tester) async { - final transactions = [ - createTransaction( - monthsAgo: 1, - description: 'Washer #5', - amount: 2.5, - ), - createTransaction( - monthsAgo: 1, - description: 'Dryer #3', - amount: 1.76, - ), - ]; - - await tester.pumpWidget(createTestWidget(transactions)); - await tester.pumpAndSettle(); - - expect(find.text('\$2.50'), findsWidgets); - expect(find.text('\$1.76'), findsWidgets); - }); - - testWidgets('multiple transactions in same month aggregate correctly', - (WidgetTester tester) async { - final transactions = [ - createTransaction( - monthsAgo: 1, - description: 'Washer #1', - amount: 2.50, - ), - createTransaction( - monthsAgo: 1, - description: 'Washer #2', - amount: 3.00, - ), - createTransaction( - monthsAgo: 1, - description: 'Washer #3', - amount: 2.75, - ), - ]; - - await tester.pumpWidget(createTestWidget(transactions)); - await tester.pumpAndSettle(); - - expect(find.text('\$8.25'), findsWidgets); - expect(find.byType(Card), findsOneWidget); - }); - - testWidgets('ignores current month transactions', - (WidgetTester tester) async { - final now = DateTime.now(); - final transactions = [ - { - 'created_at': DateTime(now.year, now.month, 15).toIso8601String(), - 'description': 'Washer #5', - 'amount': 2.50, - }, - createTransaction( - monthsAgo: 1, - description: 'Washer #5', - amount: 3.00, - ), - ]; - - await tester.pumpWidget(createTestWidget(transactions)); - await tester.pumpAndSettle(); - - expect(find.byType(Card), findsOneWidget); - expect(find.text('\$3.00'), findsExactly(2)); - }); - - testWidgets('handles transactions from exactly 11 months ago', - (WidgetTester tester) async { - final transactions = [ - createTransaction( - monthsAgo: 11, - description: 'Washer #5', - amount: 2.50, - ), - ]; - - await tester.pumpWidget(createTestWidget(transactions)); - await tester.pumpAndSettle(); - - expect(find.byType(Card), findsOneWidget); - }); - - testWidgets('handles mixed transaction types in same month', - (WidgetTester tester) async { - final transactions = [ - createTransaction( - monthsAgo: 1, - description: 'Washer #5', - amount: 2.50, - ), - createTransaction( - monthsAgo: 1, - description: 'Loyalty Payment on Washer #3', - amount: 2.00, - ), - createTransaction( - monthsAgo: 1, - description: 'Dryer #2', - amount: 1.75, - ), - createTransaction( - monthsAgo: 1, - description: 'Loyalty Payment on Dryer #1', - amount: 1.50, - ), - createTransaction( - monthsAgo: 1, - description: 'loyalty card', - amount: 10.00, - ), - ]; - - await tester.pumpWidget(createTestWidget(transactions)); - await tester.pumpAndSettle(); - - expect(find.text('\$14.25'), findsOneWidget); - expect(find.text('\$2.50'), findsWidgets); - expect(find.text('\$2.00'), findsWidgets); - expect(find.text('\$1.75'), findsWidgets); - expect(find.text('\$1.50'), findsWidgets); - expect(find.text('\$10.00'), findsWidgets); - }); + testWidgets('handles mixed transaction types in same month', ( + WidgetTester tester, + ) async { + final transactions = [ + createTransaction(monthsAgo: 1, description: 'Washer #5', amount: 2.50), + createTransaction( + monthsAgo: 1, + description: 'Loyalty Payment on Washer #3', + amount: 2.00, + ), + createTransaction(monthsAgo: 1, description: 'Dryer #2', amount: 1.75), + createTransaction( + monthsAgo: 1, + description: 'Loyalty Payment on Dryer #1', + amount: 1.50, + ), + createTransaction( + monthsAgo: 1, + description: 'loyalty card', + amount: 10.00, + ), + ]; + + await tester.pumpWidget(createWidgetUnderTest(transactions)); + await tester.pumpAndSettle(); + + expect(find.text('\$14.25'), findsOneWidget); + expect(find.text('\$2.50'), findsWidgets); + expect(find.text('\$2.00'), findsWidgets); + expect(find.text('\$1.75'), findsWidgets); + expect(find.text('\$1.50'), findsWidgets); + expect(find.text('\$10.00'), findsWidgets); + }); + + testWidgets('shows year filter options from transaction years', ( + WidgetTester tester, + ) async { + final newerDate = DateTime( + DateTime.now().year, + DateTime.now().month - 1, + 15, + ); + final olderDate = DateTime( + DateTime.now().year, + DateTime.now().month - 11, + 15, + ); + + final expectedYears = {newerDate.year, olderDate.year}.toList() + ..sort((a, b) => b.compareTo(a)); + + final transactions = [ + { + 'created_at': olderDate.toIso8601String(), + 'description': 'Washer #1', + 'amount': 2.50, + }, + { + 'created_at': newerDate.toIso8601String(), + 'description': 'Dryer #1', + 'amount': 1.75, + }, + ]; + + await tester.pumpWidget(createWidgetUnderTest(transactions)); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('year-filter-button')), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('year-filter-button'))); + await tester.pumpAndSettle(); + + expect(find.text('Year: ${expectedYears.first}'), findsOneWidget); + + for (final year in expectedYears) { + expect(find.byKey(ValueKey('year-option-$year')), findsOneWidget); + } + + await tester.tap( + find.byKey(ValueKey('year-option-${expectedYears.first}')), + ); + await tester.pumpAndSettle(); + }); + + testWidgets('selecting a year filter shows that year data', ( + WidgetTester tester, + ) async { + final newerDate = DateTime( + DateTime.now().year, + DateTime.now().month - 1, + 15, + ); + final olderDate = DateTime( + DateTime.now().year, + DateTime.now().month - 11, + 15, + ); + final newerYear = newerDate.year; + final olderYear = olderDate.year; + final olderMonthLabel = DateFormat( + 'MMM yyyy', + ).format(DateTime(olderDate.year, olderDate.month, 1)); + final newerMonthLabel = DateFormat( + 'MMM yyyy', + ).format(DateTime(newerDate.year, newerDate.month, 1)); + + final transactions = [ + { + 'created_at': olderDate.toIso8601String(), + 'description': 'Washer #5', + 'amount': 2.50, + }, + { + 'created_at': newerDate.toIso8601String(), + 'description': 'Dryer #2', + 'amount': 3.00, + }, + ]; + + await tester.pumpWidget(createWidgetUnderTest(transactions)); + await tester.pumpAndSettle(); + + if (olderYear == newerYear) { + expect(find.text(newerMonthLabel), findsOneWidget); + expect(find.text(olderMonthLabel), findsOneWidget); + return; + } + + expect(find.text(newerMonthLabel), findsOneWidget); + expect(find.text(olderMonthLabel), findsNothing); + + await selectYearFilter(tester, olderYear); + + expect(find.text(olderMonthLabel), findsOneWidget); + expect(find.text(newerMonthLabel), findsNothing); + }); + + testWidgets('can switch between year filters without errors', ( + WidgetTester tester, + ) async { + final newerDate = DateTime( + DateTime.now().year, + DateTime.now().month - 1, + 15, + ); + final olderDate = DateTime( + DateTime.now().year, + DateTime.now().month - 11, + 15, + ); + final olderYear = olderDate.year; + final newerYear = newerDate.year; + + final transactions = [ + { + 'created_at': olderDate.toIso8601String(), + 'description': 'Dryer #2', + 'amount': 1.75, + }, + { + 'created_at': newerDate.toIso8601String(), + 'description': 'Washer #2', + 'amount': 2.25, + }, + ]; + + await tester.pumpWidget(createWidgetUnderTest(transactions)); + await tester.pumpAndSettle(); + + await selectYearFilter(tester, olderYear); + + expect(tester.takeException(), isNull); + + if (olderYear != newerYear) { + await selectYearFilter(tester, newerYear); + } + + expect(tester.takeException(), isNull); + }); }); } \ No newline at end of file diff --git a/test/pages/password_reset_test.dart b/test/pages/password_reset_test.dart index 3acceb5e..49ea85b3 100644 --- a/test/pages/password_reset_test.dart +++ b/test/pages/password_reset_test.dart @@ -123,4 +123,14 @@ void main() { expect(find.text('Login Page'), findsOneWidget); }); + + testWidgets('back arrow navigates to login page', (tester) async { + await tester.pumpWidget(createWidgetUnderTest()); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.arrow_back)); + await tester.pumpAndSettle(); + + expect(find.text('Login Page'), findsOneWidget); + }); } diff --git a/test/pages/payment_page_test.dart b/test/pages/payment_page_test.dart index b5330cec..8c48297e 100644 --- a/test/pages/payment_page_test.dart +++ b/test/pages/payment_page_test.dart @@ -113,21 +113,20 @@ void main() { }); testWidgets('displays machine information after loading', ( - WidgetTester tester, - ) async { + WidgetTester tester, + ) async { when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); when( - () => mockMachineService.getMachineById('machine123'), + () => mockMachineService.getMachineById('machine123'), ).thenAnswer((_) async => {'Name': 'Washer01', 'Price': 3.50}); when( - () => mockProfileService.getUserBalanceById('user123'), + () => mockProfileService.getUserBalanceById('user123'), ).thenAnswer((_) async => {'balance': 10.0}); await tester.pumpWidget(createTestWidget('machine123')); await tester.pumpAndSettle(); expect(find.text('Machine Washer01'), findsOneWidget); - expect(find.text('\$3.50'), findsOneWidget); expect(find.text('Amount Due'), findsOneWidget); }); @@ -166,7 +165,7 @@ void main() { when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); when( () => mockMachineService.getMachineById(any()), - ).thenAnswer((_) async => {'Name': 'Washer01', 'Price': 3.50}); + ).thenAnswer((_) async => {'Name': 'Dryer01', 'Price': 3.50}); when( () => mockProfileService.getUserBalanceById(any()), ).thenAnswer((_) async => {'balance': 10.0}); @@ -174,7 +173,7 @@ void main() { await tester.pumpWidget(createTestWidget('machine123')); await tester.pumpAndSettle(); - expect(find.text('Pay \$3.50'), findsOneWidget); + expect(find.text('Pay \$1.50'), findsOneWidget); expect(find.text('Pay with Loyalty'), findsOneWidget); }); @@ -184,10 +183,10 @@ void main() { when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); when( () => mockMachineService.getMachineById(any()), - ).thenAnswer((_) async => {'Name': 'Washer01', 'Price': 3.50}); + ).thenAnswer((_) async => {'Name': 'Dryer01', 'Price': 1.50}); when(() => mockProfileService.getUserBalanceById(any())).thenAnswer( (_) async => { - 'balance': 2.0, // Insufficient balance + 'balance': 1.0, // Insufficient balance }, ); @@ -209,7 +208,7 @@ void main() { when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); when( () => mockMachineService.getMachineById(any()), - ).thenAnswer((_) async => {'Name': 'Washer01', 'Price': 3.50}); + ).thenAnswer((_) async => {'Name': 'Dryer01', 'Price': 3.50}); when( () => mockProfileService.getUserBalanceById(any()), ).thenAnswer((_) async => {'balance': 10.0}); @@ -234,12 +233,12 @@ void main() { when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); when( () => mockMachineService.getMachineById(any()), - ).thenAnswer((_) async => {'Name': 'Washer01', 'Price': 3.50}); + ).thenAnswer((_) async => {'Name': 'Dryer01', 'Price': 1.50}); when( () => mockProfileService.getUserBalanceById(any()), ).thenAnswer((_) async => {'balance': 10.0}); when( - () => mockProfileService.updateBalanceById(any()), + () => mockProfileService.updateBalanceById('user123', any()), ).thenAnswer((_) async => {}); when( () => mockMachineCommunicator.wakeDevice(any()), @@ -259,11 +258,11 @@ void main() { await tester.pump(); await tester.pumpAndSettle(); - verify(() => mockProfileService.updateBalanceById(6.5)).called(1); + verify(() => mockProfileService.updateBalanceById("user123", 8.5)).called(1); verify(() => mockMachineCommunicator.wakeDevice('machine123')).called(1); verify( () => mockTransactionService.recordTransaction( - amount: 3.50, + amount: 1.50, description: any(named: 'description'), type: 'laundry', ), @@ -276,16 +275,23 @@ void main() { when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); when( () => mockMachineService.getMachineById(any()), - ).thenAnswer((_) async => {'Name': 'Washer01', 'Price': 3.50}); + ).thenAnswer((_) async => {'Name': 'Dryer01', 'Price': 1.50}); when( () => mockProfileService.getUserBalanceById(any()), ).thenAnswer((_) async => {'balance': 10.0}); when( - () => mockProfileService.updateBalanceById(any()), + () => mockProfileService.updateBalanceById('user123', any()), ).thenAnswer((_) async => {}); when( () => mockMachineCommunicator.wakeDevice(any()), ).thenAnswer((_) async => false); + when( + () => mockTransactionService.recordTransaction( + amount: any(named: 'amount'), + description: any(named: 'description'), + type: any(named: 'type'), + ), + ).thenAnswer((_) async => {}); await tester.pumpWidget(createTestWidget('machine123')); await tester.pumpAndSettle(); @@ -310,7 +316,7 @@ void main() { when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); when( () => mockMachineService.getMachineById(any()), - ).thenAnswer((_) async => {'Name': 'Washer01', 'Price': 3.50}); + ).thenAnswer((_) async => {'Name': 'Dryer01', 'Price': 1.50}); when( () => mockProfileService.getUserBalanceById(any()), ).thenAnswer((_) async => {'balance': 10.0}); @@ -336,30 +342,37 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Machine Dryer05'), findsOneWidget); - expect(find.text('\$2.75'), findsOneWidget); - expect(find.text('Pay \$2.75'), findsOneWidget); + expect(find.text('\$1.50'), findsOneWidget); + expect(find.text('Pay \$1.50'), findsOneWidget); }); - testWidgets('sends notification after successful loyalty payment', (tester) async { + testWidgets('sends notification after successful loyalty payment', ( + tester, + ) async { when(() => mockAuthService.getCurrentUserId).thenReturn('user123'); - when(() => mockMachineService.getMachineById(any())).thenAnswer((_) async => { - 'Name': 'Washer01', - 'Price': 3.50, - }); + when( + () => mockMachineService.getMachineById(any()), + ).thenAnswer((_) async => {'Name': 'Washer01', 'Price': 3.50}); - when(() => mockProfileService.getUserBalanceById(any())).thenAnswer((_) async => { - 'balance': 10.0, - }); + when( + () => mockProfileService.getUserBalanceById(any()), + ).thenAnswer((_) async => {'balance': 10.0}); - when(() => mockProfileService.updateBalanceById(any())).thenAnswer((_) async {}); - when(() => mockMachineCommunicator.wakeDevice(any())).thenAnswer((_) async => true); + when( + () => mockProfileService.updateBalanceById('user123', any()), + ).thenAnswer((_) async {}); + when( + () => mockMachineCommunicator.wakeDevice(any()), + ).thenAnswer((_) async => true); - when(() => mockTransactionService.recordTransaction( - amount: any(named: 'amount'), - description: any(named: 'description'), - type: any(named: 'type'), - )).thenAnswer((_) async {}); + when( + () => mockTransactionService.recordTransaction( + amount: any(named: 'amount'), + description: any(named: 'description'), + type: any(named: 'type'), + ), + ).thenAnswer((_) async {}); await tester.pumpWidget(createTestWidget('machine123')); await tester.pumpAndSettle(); @@ -375,4 +388,4 @@ void main() { )).called(1); }); }); -} \ No newline at end of file +} diff --git a/test/pages/refund_page_test.dart b/test/pages/refund_page_test.dart index b87cda2f..2689bedd 100644 --- a/test/pages/refund_page_test.dart +++ b/test/pages/refund_page_test.dart @@ -10,6 +10,7 @@ import 'mocks.dart'; import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; import 'package:clean_stream_laundry_app/logic/services/transaction_service.dart'; +import 'package:clean_stream_laundry_app/widgets/transactions_search_sheet.dart'; void main() { late MockAuthService mockAuthService; @@ -62,12 +63,15 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Request Refund'), findsOneWidget); + expect(find.text('Select a transaction and describe your issue. Our team will review it shortly.'), findsOneWidget); + expect(find.byIcon(Icons.receipt_long_rounded), findsOneWidget); + expect(find.text("Submit a Refund Request"), findsOneWidget); expect(find.text('Select a Transaction'), findsOneWidget); expect( - find.text('Please explain your reason for the refund...'), + find.text('Describe the issue with your transaction...'), findsOneWidget, ); - expect(find.text('Submit Refund'), findsOneWidget); + expect(find.text('Submit Refund Request'), findsOneWidget); }); testWidgets('submit button shows error when form is invalid', ( @@ -76,7 +80,7 @@ void main() { await tester.pumpWidget(createWidgetUnderTest()); await tester.pumpAndSettle(); - final submitButton = find.widgetWithText(ElevatedButton, 'Submit Refund'); + final submitButton = find.widgetWithText(ElevatedButton, 'Submit Refund Request'); expect(submitButton, findsOneWidget); // Tap the button @@ -88,42 +92,55 @@ void main() { }); testWidgets('loads transactions on init', (tester) async { - final mockTransactions = [ - {'id': 1, 'amount': 10.0, 'date': '2024-01-01'}, - {'id': 2, 'amount': 20.0, 'date': '2024-01-02'}, - ]; when( - () => mockTransactionService.getTransactionsForUser(), - ).thenAnswer((_) async => mockTransactions); + () => mockTransactionService.getRefundableTransactionsForUser(), + ).thenAnswer((_) async => ( + transactions: ['\$10.00 - machine on Jan 01, 2024', '\$20.00 - machine on Jan 02, 2024'], + ids: [1, 2],)); await tester.pumpWidget(createWidgetUnderTest()); await tester.pumpAndSettle(); - verify(() => mockTransactionService.getTransactionsForUser()).called(1); + verify(() => mockTransactionService.getRefundableTransactionsForUser()).called(1); }); testWidgets('can select transaction from dropdown', (tester) async { - final mockTransactions = [ - {'id': 1, 'amount': 10.0, 'date': '2024-01-01'}, - ]; - when( - () => mockTransactionService.getTransactionsForUser(), - ).thenAnswer((_) async => mockTransactions); + () => mockTransactionService.getRefundableTransactionsForUser(), + ).thenAnswer((_) async => ( + transactions: ['\$10.00 - machine on Jan 01, 2024'], + ids: [1], + )); await tester.pumpWidget(createWidgetUnderTest()); await tester.pumpAndSettle(); - await tester.tap(find.byType(DropdownButtonFormField)); + // Tap the TextFormField to open the bottom sheet + await tester.tap(find.byType(TextFormField)); + await tester.pumpAndSettle(); + + // Bottom sheet is now open, tap the transaction in the list + expect(find.byType(TransactionSearchSheet), findsOneWidget); + await tester.tap(find.text('\$10.00 - machine on Jan 01, 2024')); await tester.pumpAndSettle(); + + // Verify the selection appeared in the field + expect(find.text('\$10.00 - machine on Jan 01, 2024'), findsOneWidget); }); testWidgets('can enter description text', (tester) async { + when( + () => mockTransactionService.getRefundableTransactionsForUser(), + ).thenAnswer((_) async => ( + transactions: ['\$10.00 - machine on Jan 01, 2024'], + ids: [1], + )); + await tester.pumpWidget(createWidgetUnderTest()); await tester.pumpAndSettle(); - final textField = find.byType(TextField); + final textField = find.widgetWithText(TextField, "Describe the issue with your transaction..."); await tester.enterText(textField, 'Test refund reason'); await tester.pumpAndSettle(); @@ -131,19 +148,33 @@ void main() { }); testWidgets('submit button enabled when form is valid', (tester) async { - final mockTransactions = [ - {'id': 123, 'amount': 10.0, 'date': '2024-01-01'}, - ]; - when( - () => mockTransactionService.getTransactionsForUser(), - ).thenAnswer((_) async => mockTransactions); + () => mockTransactionService.getRefundableTransactionsForUser(), + ).thenAnswer((_) async => ( + transactions: ['\$10.00 - machine on Jan 01, 2024'], + ids: [1], + )); await tester.pumpWidget(createWidgetUnderTest()); await tester.pumpAndSettle(); - await tester.enterText(find.byType(TextField), 'Test refund reason'); + // Select a transaction + await tester.tap(find.byType(TextFormField)); await tester.pumpAndSettle(); + await tester.tap(find.text('\$10.00 - machine on Jan 01, 2024')); + await tester.pumpAndSettle(); + + // Enter description + await tester.enterText( + find.widgetWithText(TextField, 'Describe the issue with your transaction...'), + 'Test refund reason', + ); + await tester.pumpAndSettle(); + + // Verify button is enabled + final button = tester.widget(find.byType(ElevatedButton)); + expect(button.onPressed, isNotNull); + expect(button.style?.backgroundColor?.resolve({}), equals(Theme.of(tester.element(find.byType(ElevatedButton))).colorScheme.primary)); }); testWidgets('handles refund submission successfully', (tester) async { @@ -223,74 +254,54 @@ void main() { }); }); - testWidgets('onChanged callback updates selectedTransaction state', ( - tester, - ) async { - final mockTransactions = [ - { - 'id': 1, - 'amount': 10.0, - 'date': '2024-01-01', - 'description': 'Wash & Fold', - 'type': 'debit', - }, - { - 'id': 2, - 'amount': 20.0, - 'date': '2024-01-02', - 'description': 'Dry Cleaning', - 'type': 'debit', - }, - ]; - + testWidgets('selecting a transaction updates state', (tester) async { when( - () => mockTransactionService.getTransactionsForUser(), - ).thenAnswer((_) async => mockTransactions); + () => mockTransactionService.getRefundableTransactionsForUser(), + ).thenAnswer((_) async => ( + transactions: ['\$10.00 - machine on Jan 01, 2024', '\$20.00 - machine on Jan 02, 2024'], + ids: [1, 2], + )); await tester.pumpWidget(createWidgetUnderTest()); await tester.pumpAndSettle(); - final dropdown = find.byType(DropdownButtonFormField); - expect(dropdown, findsOneWidget); - - await tester.tap(dropdown); + // Open the search sheet + await tester.tap(find.byType(TextFormField)); await tester.pumpAndSettle(); - final hasItems = find.byType(DropdownMenuItem).evaluate().isNotEmpty; + expect(find.byType(TransactionSearchSheet), findsOneWidget); - expect( - hasItems || find.text('Select a Transaction').evaluate().isNotEmpty, - true, - ); + // Select a transaction + await tester.tap(find.text('\$10.00 - machine on Jan 01, 2024')); + await tester.pumpAndSettle(); + + // Verify the TextFormField now shows the selected transaction + expect(find.text('\$10.00 - machine on Jan 01, 2024'), findsOneWidget); }); testWidgets('_handleRefund calls all required services in correct order', ( - tester, - ) async { - final mockTransactions = [ - { - 'id': 123, - 'amount': 25.50, - 'date': '2024-01-01', - 'description': 'Test Transaction', - 'type': 'debit', - }, - ]; - + tester, + ) async { when( - () => mockTransactionService.getTransactionsForUser(), - ).thenAnswer((_) async => mockTransactions); + () => mockTransactionService.getRefundableTransactionsForUser(), + ).thenAnswer((_) async => ( + transactions: ['\$25.50 - machine on Jan 01, 2024'], + ids: [123], + )); when( - () => mockProfileService.getUserNameById('test-user-id'), + () => mockAuthService.getCurrentUserId, + ).thenReturn('test-user-id'); + when( + () => mockProfileService.getUserNameById('test-user-id'), ).thenAnswer((_) async => 'Test User'); when( - () => mockTransactionService.recordRefundRequest( + () => mockTransactionService.recordRefundRequest( transaction_id: any(named: 'transaction_id'), description: any(named: 'description'), ), - ).thenAnswer((_) async => "25.50"); + ).thenAnswer((_) async => '25.50'); when( - () => mockEdgeFunctionService.runEdgeFunction( + () => mockEdgeFunctionService.runEdgeFunction( name: any(named: 'name'), body: any(named: 'body'), ), @@ -299,52 +310,34 @@ void main() { await tester.pumpWidget(createWidgetUnderTest()); await tester.pumpAndSettle(); - await tester.enterText(find.byType(TextField), 'Test refund reason'); + // Select a transaction + await tester.tap(find.byType(TextFormField)); + await tester.pumpAndSettle(); + await tester.tap(find.text('\$25.50 - machine on Jan 01, 2024')); await tester.pumpAndSettle(); - verify(() => mockTransactionService.getTransactionsForUser()).called(1); - }); - - testWidgets('_handleRefund completes full flow when form is submitted', ( - tester, - ) async { - final mockTransactions = [ - { - 'id': 123, - 'amount': 25.50, - 'date': '2024-01-01', - 'description': 'Test Transaction', - 'type': 'debit', - }, - ]; - - when( - () => mockTransactionService.getTransactionsForUser(), - ).thenAnswer((_) async => mockTransactions); - when( - () => mockProfileService.getUserNameById('test-user-id'), - ).thenAnswer((_) async => 'Test User'); - when( - () => mockTransactionService.recordRefundRequest( - transaction_id: any(named: 'transaction_id'), - description: any(named: 'description'), - ), - ).thenAnswer((_) async => "25.50"); - when( - () => mockEdgeFunctionService.runEdgeFunction( - name: any(named: 'name'), - body: any(named: 'body'), - ), - ).thenAnswer((_) async => null); - - await tester.pumpWidget(createWidgetUnderTest()); + // Enter description + await tester.enterText( + find.widgetWithText(TextField, 'Describe the issue with your transaction...'), + 'Test refund reason', + ); await tester.pumpAndSettle(); - await tester.enterText(find.byType(TextField), 'Test refund reason'); + // Submit + await tester.tap(find.byType(ElevatedButton)); await tester.pumpAndSettle(); - final submitButton = find.widgetWithText(ElevatedButton, 'Submit Refund'); - expect(submitButton, findsOneWidget); + // Verify services were called + verify(() => mockTransactionService.getRefundableTransactionsForUser()).called(1); + verify(() => mockProfileService.getUserNameById('test-user-id')).called(1); + verify(() => mockTransactionService.recordRefundRequest( + transaction_id: '123', + description: 'Test refund reason', + )).called(1); + verify(() => mockEdgeFunctionService.runEdgeFunction( + name: 'refund-email', + body: any(named: 'body'), + )).called(1); }); testWidgets('_handleRefund early return when userId is null', (tester) async { @@ -368,32 +361,25 @@ void main() { }); testWidgets('verifies edge function is called with correct parameters', ( - tester, - ) async { - final mockTransactions = [ - { - 'id': 456, - 'amount': 50.00, - 'date': '2024-01-15', - 'description': 'Premium Service', - 'type': 'debit', - }, - ]; - + tester, + ) async { when( - () => mockTransactionService.getTransactionsForUser(), - ).thenAnswer((_) async => mockTransactions); + () => mockTransactionService.getRefundableTransactionsForUser(), + ).thenAnswer((_) async => ( + transactions: ['\$50.00 - machine on Jan 15, 2024'], + ids: [456], + )); when( - () => mockProfileService.getUserNameById('test-user-id'), + () => mockProfileService.getUserNameById('test-user-id'), ).thenAnswer((_) async => 'John Doe'); when( - () => mockTransactionService.recordRefundRequest( + () => mockTransactionService.recordRefundRequest( transaction_id: any(named: 'transaction_id'), description: any(named: 'description'), ), - ).thenAnswer((_) async => "50.00"); + ).thenAnswer((_) async => '50.00'); when( - () => mockEdgeFunctionService.runEdgeFunction( + () => mockEdgeFunctionService.runEdgeFunction( name: 'refund-email', body: any(named: 'body'), ), @@ -401,36 +387,55 @@ void main() { await tester.pumpWidget(createWidgetUnderTest()); await tester.pumpAndSettle(); - verify(() => mockTransactionService.getTransactionsForUser()).called(1); - }); - testWidgets('complete refund submission flow with dialog and navigation', ( - tester, - ) async { - final mockTransactions = [ - { - 'id': 789, - 'amount': 100.00, - 'date': '2024-01-20', - 'description': 'Large Order', - 'type': 'debit', + // Select transaction + await tester.tap(find.byType(TextFormField)); + await tester.pumpAndSettle(); + await tester.tap(find.text('\$50.00 - machine on Jan 15, 2024')); + await tester.pumpAndSettle(); + + // Enter description + await tester.enterText( + find.widgetWithText(TextField, 'Describe the issue with your transaction...'), + 'Wrong charge', + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + + verify(() => mockEdgeFunctionService.runEdgeFunction( + name: 'refund-email', + body: { + 'username': 'John Doe', + 'user_id': 'test-user-id', + 'transaction_id': '456', + 'amount': '50.00', + 'description': 'Wrong charge', }, - ]; + )).called(1); + }); + testWidgets('complete refund submission flow with dialog and navigation', ( + tester, + ) async { when( - () => mockTransactionService.getTransactionsForUser(), - ).thenAnswer((_) async => mockTransactions); + () => mockTransactionService.getRefundableTransactionsForUser(), + ).thenAnswer((_) async => ( + transactions: ['\$100.00 - machine on Jan 20, 2024'], + ids: [789], + )); when( - () => mockProfileService.getUserNameById('test-user-id'), + () => mockProfileService.getUserNameById('test-user-id'), ).thenAnswer((_) async => 'Jane Smith'); when( - () => mockTransactionService.recordRefundRequest( + () => mockTransactionService.recordRefundRequest( transaction_id: any(named: 'transaction_id'), description: any(named: 'description'), ), - ).thenAnswer((_) async => "100.00"); + ).thenAnswer((_) async => '100.00'); when( - () => mockEdgeFunctionService.runEdgeFunction( + () => mockEdgeFunctionService.runEdgeFunction( name: 'refund-email', body: any(named: 'body'), ), @@ -440,40 +445,29 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Request Refund'), findsOneWidget); - expect(find.text('Submit Refund'), findsOneWidget); + expect(find.text('Submit Refund Request'), findsOneWidget); }); testWidgets('_handleRefund executes all service calls correctly', ( - tester, - ) async { - final recentDate = DateTime.now().subtract(Duration(days: 5)); - final formattedDate = recentDate.toIso8601String(); - - final mockTransactions = [ - { - 'id': 123, - 'amount': 25.50, - 'created_at': formattedDate, - 'description': 'Wash & Fold', - 'type': 'debit', - 'user_id': 'test-user-id', - }, - ]; - + tester, + ) async { when( - () => mockTransactionService.getTransactionsForUser(), - ).thenAnswer((_) async => mockTransactions); + () => mockTransactionService.getRefundableTransactionsForUser(), + ).thenAnswer((_) async => ( + transactions: ['\$25.50 - machine on Jan 01, 2024'], + ids: [123], + )); when( - () => mockProfileService.getUserNameById('test-user-id'), + () => mockProfileService.getUserNameById('test-user-id'), ).thenAnswer((_) async => 'Test User'); when( - () => mockTransactionService.recordRefundRequest( + () => mockTransactionService.recordRefundRequest( transaction_id: any(named: 'transaction_id'), description: any(named: 'description'), ), - ).thenAnswer((_) async => "25.50"); + ).thenAnswer((_) async => '25.50'); when( - () => mockEdgeFunctionService.runEdgeFunction( + () => mockEdgeFunctionService.runEdgeFunction( name: 'refund-email', body: any(named: 'body'), ), @@ -482,48 +476,37 @@ void main() { await tester.pumpWidget(createWidgetUnderTest()); await tester.pumpAndSettle(); - final RefundPageState state = tester.state(find.byType(RefundPage)); - - await tester.pump(const Duration(milliseconds: 100)); + // Select transaction + await tester.tap(find.byType(TextFormField)); await tester.pumpAndSettle(); - - expect(state.recentTransactions.isNotEmpty, true); - expect(state.recentTransactionIDs.isNotEmpty, true); - - state.setState(() { - state.selectedTransactionIndex = 0; - state.selectedTransaction = state.recentTransactions[0]; - state.descriptionController.text = 'I want a refund please'; - }); + await tester.tap(find.text('\$25.50 - machine on Jan 01, 2024')); await tester.pumpAndSettle(); - final submitButton = find.widgetWithText(ElevatedButton, 'Submit Refund'); - await tester.tap(submitButton); + // Enter description + await tester.enterText( + find.widgetWithText(TextField, 'Describe the issue with your transaction...'), + 'I want a refund please', + ); + await tester.pumpAndSettle(); - for (int i = 0; i < 10; i++) { - await tester.pump(const Duration(milliseconds: 100)); - } + await tester.tap(find.byType(ElevatedButton)); await tester.pumpAndSettle(); verify(() => mockProfileService.getUserNameById('test-user-id')).called(1); - verify( - () => mockTransactionService.recordRefundRequest( - transaction_id: '123', - description: 'I want a refund please', - ), - ).called(1); - verify( - () => mockEdgeFunctionService.runEdgeFunction( - name: 'refund-email', - body: { - 'username': 'Test User', - 'user_id': 'test-user-id', - 'transaction_id': '123', - 'amount': '25.50', - 'description': 'I want a refund please', - }, - ), - ).called(1); + verify(() => mockTransactionService.recordRefundRequest( + transaction_id: '123', + description: 'I want a refund please', + )).called(1); + verify(() => mockEdgeFunctionService.runEdgeFunction( + name: 'refund-email', + body: { + 'username': 'Test User', + 'user_id': 'test-user-id', + 'transaction_id': '123', + 'amount': '25.50', + 'description': 'I want a refund please', + }, + )).called(1); }); testWidgets( @@ -710,5 +693,40 @@ void main() { }, ), ).called(1); + + }); + + testWidgets('disclosure widget displays correctly', (tester) async { + when( + () => mockTransactionService.getRefundableTransactionsForUser(), + ).thenAnswer((_) async => (transactions: [], ids: [])); + + await tester.pumpWidget(createWidgetUnderTest()); + await tester.pumpAndSettle(); + + expect( + find.textContaining('Refund requests are reviewed within 3–5 business days.'), + findsOneWidget, + ); + expect( + find.textContaining('Approved refunds will be returned to your loyalty card balance.'), + findsOneWidget, + ); + expect( + find.textContaining('We reserve the right to deny requests that do not meet our refund policy criteria.'), + findsOneWidget, + ); + + expect(find.byIcon(Icons.info_outline_rounded), findsOneWidget); + + final container = tester.widget( + find.ancestor( + of: find.byIcon(Icons.info_outline_rounded), + matching: find.byType(Container), + ).first, + ); + final decoration = container.decoration as BoxDecoration; + expect(decoration.color, const Color(0xFFFFFDE7).withOpacity(0.8)); + expect(decoration.borderRadius, BorderRadius.circular(14)); }); } diff --git a/test/pages/reset_protected_page_test.dart b/test/pages/reset_protected_page_test.dart index af660ed2..f8f664e9 100644 --- a/test/pages/reset_protected_page_test.dart +++ b/test/pages/reset_protected_page_test.dart @@ -1,4 +1,5 @@ import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; +import 'package:clean_stream_laundry_app/logic/parsing/password_parser.dart'; import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; import 'package:clean_stream_laundry_app/pages/reset_protected_page.dart'; import 'package:flutter/material.dart'; @@ -24,14 +25,14 @@ void main() { tearDown(() => GetIt.instance.reset()); - Widget createWidgetUnderTest({Uri? incomingUri}) { + Widget createWidgetUnderTest() { final router = GoRouter( initialLocation: '/reset-protected', routes: [ GoRoute( path: '/reset-protected', builder: (context, state) => - ResetProtectedPage(incomingUri: incomingUri), + ResetProtectedPage(), ), GoRoute( path: '/login', @@ -43,145 +44,93 @@ void main() { return MaterialApp.router(routerConfig: router); } - testWidgets('shows invalid link state for non-reset uri', (tester) async { - await tester.pumpWidget( - createWidgetUnderTest(incomingUri: Uri.parse('clean-stream://other')), - ); - await tester.pumpAndSettle(); - - expect(find.text('Invalid or expired reset link'), findsOneWidget); - expect(find.text('Back to Login'), findsOneWidget); - }); + testWidgets('validates short password', (tester) async { - testWidgets('shows invalid link when code is missing', (tester) async { await tester.pumpWidget( - createWidgetUnderTest( - incomingUri: Uri.parse('clean-stream://reset-protected'), - ), + createWidgetUnderTest() ); await tester.pumpAndSettle(); - expect(find.text('Invalid or expired reset link'), findsOneWidget); - }); - - testWidgets('shows invalid link when code exchange fails', (tester) async { - when( - () => mockAuthService.exchangeCodeForSession('abc'), - ).thenAnswer((_) async => AuthenticationResponses.failure); - - await tester.pumpWidget( - createWidgetUnderTest( - incomingUri: Uri.parse('clean-stream://reset-protected?code=abc'), - ), - ); + await tester.enterText(find.byType(TextField).at(0), 'short'); + await tester.enterText(find.byType(TextField).at(1), 'short'); + await tester.tap(find.byType(ElevatedButton)); await tester.pumpAndSettle(); + String? validations = PasswordParser.process("short"); - expect(find.text('Invalid or expired reset link'), findsOneWidget); + if (validations != null) { + expect(find.text(validations), findsWidgets); + } }); - testWidgets('renders form when link is valid', (tester) async { - when( - () => mockAuthService.exchangeCodeForSession('abc'), - ).thenAnswer((_) async => AuthenticationResponses.success); - - await tester.pumpWidget( - createWidgetUnderTest( - incomingUri: Uri.parse('clean-stream://reset-protected?code=abc'), - ), - ); - await tester.pumpAndSettle(); - - expect(find.text('Set a new password'), findsOneWidget); - expect(find.byType(TextFormField), findsOneWidget); - expect(find.text('Set Password'), findsOneWidget); - }); + testWidgets('shows success message when update is successful', (tester) async { - testWidgets('validates short password', (tester) async { - when( - () => mockAuthService.exchangeCodeForSession('abc'), - ).thenAnswer((_) async => AuthenticationResponses.success); + when(() => mockAuthService.updatePassword(any())).thenAnswer((_) async => AuthenticationResponses.success); await tester.pumpWidget( - createWidgetUnderTest( - incomingUri: Uri.parse('clean-stream://reset-protected?code=abc'), - ), + createWidgetUnderTest() ); + await tester.pumpAndSettle(); - await tester.enterText(find.byType(TextFormField), 'short'); - await tester.tap(find.text('Set Password')); + await tester.enterText(find.byType(TextField).at(0), 'Password123&'); + await tester.enterText(find.byType(TextField).at(1), 'Password123&'); + await tester.tap(find.byType(ElevatedButton)); await tester.pumpAndSettle(); - expect(find.text('Password must be at least 8 characters'), findsOneWidget); + verify(() => mockAuthService.updatePassword('Password123&')).called(1); + expect(find.text("Password reset successful"), findsWidgets); + expect(find.text("Login Page"), findsOneWidget); }); - testWidgets('submits new password and navigates to login', (tester) async { - when( - () => mockAuthService.exchangeCodeForSession('abc'), - ).thenAnswer((_) async => AuthenticationResponses.success); - when( - () => mockAuthService.updatePassword(any()), - ).thenAnswer((_) async => AuthenticationResponses.success); + testWidgets('shows failure message when update fails', (tester) async { + + when(() => mockAuthService.updatePassword(any())).thenThrow(Exception('network')); await tester.pumpWidget( - createWidgetUnderTest( - incomingUri: Uri.parse('clean-stream://reset-protected?code=abc'), - ), + createWidgetUnderTest() ); + await tester.pumpAndSettle(); - await tester.enterText(find.byType(TextFormField), 'password123'); - await tester.tap(find.text('Set Password')); + await tester.enterText(find.byType(TextField).at(0), 'Password123&'); + await tester.enterText(find.byType(TextField).at(1), 'Password123&'); + await tester.tap(find.byType(ElevatedButton)); await tester.pumpAndSettle(); - verify(() => mockAuthService.updatePassword('password123')).called(1); - expect(find.text('Password reset successful'), findsOneWidget); - expect(find.text('Login Page'), findsOneWidget); + verify(() => mockAuthService.updatePassword('Password123&')).called(1); + expect(find.text('Failed to reset password'), findsWidgets); }); - testWidgets('shows failure message when update fails', (tester) async { + testWidgets('shows failure message when exchangeCodeForSession throws', (tester) async { when( () => mockAuthService.exchangeCodeForSession('abc'), ).thenAnswer((_) async => AuthenticationResponses.success); when( () => mockAuthService.updatePassword(any()), - ).thenAnswer((_) async => AuthenticationResponses.failure); + ).thenThrow(Exception('network')); await tester.pumpWidget( - createWidgetUnderTest( - incomingUri: Uri.parse('clean-stream://reset-protected?code=abc'), - ), + createWidgetUnderTest() ); await tester.pumpAndSettle(); - await tester.enterText(find.byType(TextFormField), 'password123'); - await tester.tap(find.text('Set Password')); + await tester.enterText(find.byType(TextField).at(0), 'Password123&'); + await tester.enterText(find.byType(TextField).at(1), 'Password123&'); + await tester.tap(find.byType(ElevatedButton)); await tester.pumpAndSettle(); - verify(() => mockAuthService.updatePassword('password123')).called(1); - expect(find.text('Failed to reset password'), findsOneWidget); + verify(() => mockAuthService.updatePassword('Password123&')).called(1); + expect(find.text('Failed to reset password'), findsWidgets); }); - testWidgets('shows failure message when update throws', (tester) async { - when( - () => mockAuthService.exchangeCodeForSession('abc'), - ).thenAnswer((_) async => AuthenticationResponses.success); - when( - () => mockAuthService.updatePassword(any()), - ).thenThrow(Exception('network')); + testWidgets('Verifies that the image is present', (tester) async { await tester.pumpWidget( - createWidgetUnderTest( - incomingUri: Uri.parse('clean-stream://reset-protected?code=abc'), - ), + createWidgetUnderTest() ); - await tester.pumpAndSettle(); - await tester.enterText(find.byType(TextFormField), 'password123'); - await tester.tap(find.text('Set Password')); await tester.pumpAndSettle(); - verify(() => mockAuthService.updatePassword('password123')).called(1); - expect(find.text('Failed to reset password'), findsOneWidget); + expect(find.byType(Image),findsOneWidget); }); } diff --git a/test/pages/start_machine_page_test.dart b/test/pages/start_machine_page_test.dart index a3d4ef82..a97cf349 100644 --- a/test/pages/start_machine_page_test.dart +++ b/test/pages/start_machine_page_test.dart @@ -1,25 +1,53 @@ +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:clean_stream_laundry_app/logic/services/profile_service.dart'; +import 'package:clean_stream_laundry_app/logic/viewmodels/loyalty_view_model.dart'; import 'package:clean_stream_laundry_app/pages/start_machine_page.dart'; -import 'package:clean_stream_laundry_app/widgets/large_button.dart'; +import 'package:clean_stream_laundry_app/services/kisi/door_unlocker.dart'; +import 'package:clean_stream_laundry_app/widgets/qr_button.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; import 'package:mocktail/mocktail.dart'; -class MockNavigatorObserver extends Mock implements NavigatorObserver {} +class MockDoorUnlocker extends Mock implements DoorUnlocker {} +class MockLoyaltyViewModel extends Mock implements LoyaltyViewModel {} +class MockProfileService extends Mock implements ProfileService {} +class MockAuthService extends Mock implements AuthService {} void main() { - late GoRouter router; - late MockNavigatorObserver navigatorObserver; + late MockDoorUnlocker mockUnlocker; + late MockLoyaltyViewModel mockViewModel; + late MockProfileService mockProfileService; + late MockAuthService mockAuthService; setUp(() { - navigatorObserver = MockNavigatorObserver(); + mockUnlocker = MockDoorUnlocker(); + mockViewModel = MockLoyaltyViewModel(); + mockProfileService = MockProfileService(); + mockAuthService = MockAuthService(); - router = GoRouter( - observers: [navigatorObserver], + final getIt = GetIt.instance; + + if (getIt.isRegistered()) getIt.unregister(); + if (getIt.isRegistered()) getIt.unregister(); + if (getIt.isRegistered()) getIt.unregister(); + + getIt.registerSingleton(mockViewModel); + getIt.registerSingleton(mockProfileService); + getIt.registerSingleton(mockAuthService); + + when(() => mockAuthService.getCurrentUserId).thenReturn("user123"); + when(() => mockProfileService.getUserBalanceById("user123")) + .thenAnswer((_) async => {"balance": 50.0}); + }); + + Widget createStartPageTestApp(DoorUnlocker unlocker) { + final router = GoRouter( routes: [ GoRoute( path: '/', - builder: (_, __) => const StartPage(), + builder: (_, __) => StartPage(doorUnlocker: unlocker), ), GoRoute( path: '/scanner', @@ -27,26 +55,138 @@ void main() { ), ], ); - }); + return MaterialApp.router(routerConfig: router); + } - Widget createTestApp() { - return MaterialApp.router( - routerConfig: router, + Future scrollToUnlockButton(WidgetTester tester) async { + final scrollViewFinder = find.descendant( + of: find.byType(StartPage), + matching: find.byType(SingleChildScrollView), ); + expect(scrollViewFinder, findsOneWidget); + + await tester.drag(scrollViewFinder, const Offset(0, -500)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); } testWidgets('Tapping QR button navigates to /scanner', (tester) async { - await tester.pumpWidget(createTestApp()); + when(() => mockViewModel.userBalance).thenReturn(50.0); + + await tester.pumpWidget(createStartPageTestApp(mockUnlocker)); await tester.pumpAndSettle(); - final qrButton = find.widgetWithText(LargeButton, "Scan QR code"); + await scrollToUnlockButton(tester); + final qrButton = find.widgetWithText(QRButton, "Scan QR code"); expect(qrButton, findsOneWidget); await tester.tap(qrButton); await tester.pumpAndSettle(); - // Verify we navigated to the scanner page expect(find.text("Scanner Page"), findsOneWidget); }); -} + + testWidgets('Unlock button shows searching dialog', (tester) async { + when(() => mockViewModel.userBalance).thenReturn(50.0); + when(() => mockUnlocker.unlockNearestDoor()).thenAnswer((_) async { + await Future.delayed(const Duration(milliseconds: 50)); + return true; + }); + + await tester.pumpWidget(createStartPageTestApp(mockUnlocker)); + await tester.pumpAndSettle(); + await scrollToUnlockButton(tester); + + await tester.tap(find.text("Unlock Door")); + await tester.pump(const Duration(milliseconds: 20)); + + expect(find.byType(Dialog), findsOneWidget); + expect(find.textContaining("Finding Nearby Doors"), findsOneWidget); + await tester.pumpAndSettle(); + }); + + testWidgets('Successful unlock closes searching dialog and shows success dialog', + (tester) async { + when(() => mockViewModel.userBalance).thenReturn(50.0); + when(() => mockUnlocker.unlockNearestDoor()).thenAnswer((_) async => true); + + await tester.pumpWidget(createStartPageTestApp(mockUnlocker)); + await tester.pumpAndSettle(); + + await scrollToUnlockButton(tester); + + await tester.tap(find.text("Unlock Door")); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(seconds: 2)); + + expect(find.text("Door Unlocked!"), findsOneWidget); + }); + + testWidgets('Failed unlock shows failure dialog', (tester) async { + when(() => mockViewModel.userBalance).thenReturn(50.0); + when(() => mockUnlocker.unlockNearestDoor()).thenAnswer((_) async => false); + + await tester.pumpWidget(createStartPageTestApp(mockUnlocker)); + await tester.pumpAndSettle(); + + await scrollToUnlockButton(tester); + + await tester.tap(find.text("Unlock Door")); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(seconds: 2)); + + expect(find.text("No Nearby Doors Found"), findsOneWidget); + }); + + testWidgets('shows low balance dialog when balance is below 20', (tester) async { + const String testUid = "user123"; + when(() => mockAuthService.getCurrentUserId).thenReturn(testUid); + when(() => mockProfileService.getUserBalanceById(testUid)) + .thenAnswer((_) async => {"balance": 15.0}); + + await tester.pumpWidget(createStartPageTestApp(mockUnlocker)); + await tester.pump(); + await tester.pumpAndSettle(); + await scrollToUnlockButton(tester); + + final unlockButton = find.text('Unlock Door'); + expect(unlockButton, findsOneWidget); + await tester.tap(unlockButton); + await tester.pumpAndSettle(); + + expect(find.text('Low Balance'), findsOneWidget); + expect(find.textContaining('at least 20.00'), findsOneWidget); + verifyNever(() => mockUnlocker.unlockNearestDoor()); + }); + + testWidgets('allows unlocking when balance is exactly 20', (tester) async { + when(() => mockViewModel.userBalance).thenReturn(20.0); + when(() => mockUnlocker.unlockNearestDoor()).thenAnswer((_) async => true); + + await tester.pumpWidget(createStartPageTestApp(mockUnlocker)); + await tester.pumpAndSettle(); + + await scrollToUnlockButton(tester); + + await tester.tap(find.text("Unlock Door")); + await tester.pumpAndSettle(); + + verify(() => mockUnlocker.unlockNearestDoor()).called(1); + }); + + testWidgets('allows unlocking when balance is above 20', (tester) async { + when(() => mockViewModel.userBalance).thenReturn(25.0); + when(() => mockUnlocker.unlockNearestDoor()).thenAnswer((_) async => true); + + await tester.pumpWidget(createStartPageTestApp(mockUnlocker)); + await tester.pumpAndSettle(); + + await scrollToUnlockButton(tester); + + await tester.tap(find.text("Unlock Door")); + await tester.pumpAndSettle(); + + verify(() => mockUnlocker.unlockNearestDoor()).called(1); + }); +} \ No newline at end of file diff --git a/test/pages/verify_code_page_test.dart b/test/pages/verify_code_page_test.dart new file mode 100644 index 00000000..1ddeb326 --- /dev/null +++ b/test/pages/verify_code_page_test.dart @@ -0,0 +1,225 @@ +import 'package:clean_stream_laundry_app/logic/enums/authentication_response_enum.dart'; +import 'package:clean_stream_laundry_app/logic/services/auth_service.dart'; +import 'package:clean_stream_laundry_app/pages/reset_protected_page.dart'; +import 'package:clean_stream_laundry_app/pages/verify_code_page.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../logic/viewmodels/mocks.dart'; + +void main() { + late MockAuthService mockAuthService; + + setUp(() { + mockAuthService = MockAuthService(); + + final getIt = GetIt.instance; + if (getIt.isRegistered()) { + getIt.unregister(); + } + + getIt.registerSingleton(mockAuthService); + }); + + tearDown(() => GetIt.instance.reset()); + + Widget createWidgetUnderTest() { + final router = GoRouter( + initialLocation: '/verify-code', + routes: [ + GoRoute( + path: '/verify-code', + builder: (context, state) => + CodeVerificationPage(email: "testEmail"), + ), + GoRoute( + path: '/reset-protected', + builder: (context, state) => + ResetProtectedPage(), + ) + ], + ); + + return MaterialApp.router(routerConfig: router); + } + + group("UI elements render correctly", (){ + + testWidgets('validates title renders', (tester) async { + + await tester.pumpWidget( + createWidgetUnderTest() + ); + + await tester.pumpAndSettle(); + + expect(find.text("Verify Code"), findsOneWidget); + + }); + + testWidgets('Subheading renders', (tester) async { + + await tester.pumpWidget( + createWidgetUnderTest() + ); + + await tester.pumpAndSettle(); + + expect(find.text("Enter Verification Code"), findsOneWidget); + + }); + + testWidgets('Instructions render', (tester) async { + + await tester.pumpWidget( + createWidgetUnderTest() + ); + + await tester.pumpAndSettle(); + + expect(find.text("We sent a 6-digit code to"), findsOneWidget); + + }); + + testWidgets('Text field renders', (tester) async { + + await tester.pumpWidget( + createWidgetUnderTest() + ); + + await tester.pumpAndSettle(); + + expect(find.byType(TextField), findsOneWidget); + + }); + + testWidgets('Verify Button renders', (tester) async { + + await tester.pumpWidget( + createWidgetUnderTest() + ); + + await tester.pumpAndSettle(); + + expect(find.byType(ElevatedButton), findsOneWidget); + expect(find.text("Verify"), findsOneWidget); + }); + + testWidgets('Resend Button renders', (tester) async { + + await tester.pumpWidget( + createWidgetUnderTest() + ); + + await tester.pumpAndSettle(); + + expect(find.byType(TextButton), findsOneWidget); + expect(find.text("Resend code"), findsOneWidget); + }); + + }); + + group("Logic tests", (){ + + testWidgets('Error is thrown if a code entered is too short', (tester) async { + + await tester.pumpWidget( + createWidgetUnderTest() + ); + + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField).at(0), '1234'); + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + + expect(find.text('Please enter the 6-digit code'), findsWidgets); + + }); + + testWidgets('Navigates correctly if a correct code was entered', (tester) async { + + when(() => mockAuthService.verifyCode(email: any(named:"email"), code: any(named:"code"))).thenAnswer((_) async => AuthenticationResponses.success); + + await tester.pumpWidget( + createWidgetUnderTest() + ); + + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), '123456'); + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + + expect(find.text('Reset Password'), findsWidgets); + + }); + + testWidgets('Shows error if the code verification was not correct', (tester) async { + + when(() => mockAuthService.verifyCode(email: any(named:"email"), code: any(named:"code"))).thenAnswer((_) async => AuthenticationResponses.failure); + + await tester.pumpWidget( + createWidgetUnderTest() + ); + + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), '123456'); + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + + expect(find.text("Invalid or expired code"), findsWidgets); + + }); + + testWidgets('Shows error if exception was thrown', (tester) async { + + when(() => mockAuthService.verifyCode(email: any(named:"email"), code: any(named:"code"))).thenThrow(Exception()); + + await tester.pumpWidget( + createWidgetUnderTest() + ); + + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), '123456'); + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + + expect(find.text("Something went wrong. Try again"), findsWidgets); + + }); + + testWidgets('Shows message if email reset was successful', (tester) async { + + when(() => mockAuthService.resetPassword(any())).thenAnswer((_) async => AuthenticationResponses.success); + + await tester.pumpWidget( + createWidgetUnderTest() + ); + + await tester.tap(find.byType(TextButton)); + await tester.pumpAndSettle(); + + expect(find.text("Password reset email sent! Check your email."), findsWidgets); + + }); + + testWidgets('Shows message if email reset was unsuccessful', (tester) async { + + when(() => mockAuthService.resetPassword(any())).thenAnswer((_) async => AuthenticationResponses.failure); + + await tester.pumpWidget( + createWidgetUnderTest() + ); + + await tester.tap(find.byType(TextButton)); + await tester.pumpAndSettle(); + + expect(find.text("Failed to send reset email."), findsWidgets); + + }); + + }); + +} \ No newline at end of file diff --git a/test/services/kisi/door_unlocker_test.dart b/test/services/kisi/door_unlocker_test.dart new file mode 100644 index 00000000..c264d3c8 --- /dev/null +++ b/test/services/kisi/door_unlocker_test.dart @@ -0,0 +1,50 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:clean_stream_laundry_app/services/kisi/door_unlocker.dart'; + +void main() { + group('DoorUnlocker Tests', () { + late DoorUnlocker unlocker; + + setUp(() { + unlocker = DoorUnlocker(); + }); + + test('getNearbyDoors returns all mapped doors', () async { + final doors = await unlocker.getNearbyDoors(); + + expect(doors.length, 2); + expect(doors, contains("Front Door")); + expect(doors, contains("Back Door")); + }); + + test('unlockDoor returns true for normal doors', () async { + final result = await unlocker.unlockDoor("Front Door"); + expect(result, true); + }); + + test('unlockDoor returns false for Broken Door', () async { + final result = await unlocker.unlockDoor("Broken Door"); + expect(result, false); + }); + + test('unlockNearestDoor unlocks the first door', () async { + final result = await unlocker.unlockNearestDoor(); + expect(result, true); + }); + + test('unlockNearestDoor returns false when cancelled after fetching doors', () async { + final future = unlocker.unlockNearestDoor(); + + unlocker.cancelUnlockingDoor(); + + final result = await future; + expect(result, false); + }); + + test('cancelUnlockingDoor sets cancelled flag', () { + expect(unlocker.cancelled, false); + unlocker.cancelUnlockingDoor(); + expect(unlocker.cancelled, true); + }); + }); +} \ No newline at end of file diff --git a/test/services/stripe/stripe_service_mobile_test.dart b/test/services/stripe/stripe_service_mobile_test.dart index 18ffa116..757feaa5 100644 --- a/test/services/stripe/stripe_service_mobile_test.dart +++ b/test/services/stripe/stripe_service_mobile_test.dart @@ -58,7 +58,7 @@ void main() { // Assert (interaction) verify(() => mockEdgeFunctionService.runEdgeFunction( name: 'paymentIntent', - body: {'amount': '260', 'currency': 'usd'}, + body: {'amount': 260, 'currency': 'usd'}, )).called(1); }); @@ -191,7 +191,7 @@ void main() { expect(result, "testSecret123"); verify(() => mockEdgeFunctionService.runEdgeFunction( name: 'paymentIntent', - body: {'amount': '2570', 'currency': 'usd'}, + body: {'amount': 2570, 'currency': 'usd'}, )).called(1); }); @@ -261,27 +261,27 @@ void main() { group("convertDollarsToCents", () { test("converts dollars to cents correctly", () { - expect(stripeService.convertDollarsToCents(2.75), "275"); + expect(stripeService.convertDollarsToCents(2.75), 275); }); test("handles zero amount", () { - expect(stripeService.convertDollarsToCents(0), "0"); + expect(stripeService.convertDollarsToCents(0), 0); }); test("handles whole dollar amounts", () { - expect(stripeService.convertDollarsToCents(10.00), "1000"); + expect(stripeService.convertDollarsToCents(10.00), 1000); }); test("handles large amounts", () { - expect(stripeService.convertDollarsToCents(1234.56), "123456"); + expect(stripeService.convertDollarsToCents(1234.56), 123456); }); test("handles small decimal amounts", () { - expect(stripeService.convertDollarsToCents(0.01), "1"); + expect(stripeService.convertDollarsToCents(0.01), 1); }); test("rounds down fractional cents", () { - expect(stripeService.convertDollarsToCents(1.999), "199"); + expect(stripeService.convertDollarsToCents(1.999), 199); }); }); }); diff --git a/test/services/supabase/authentication/authenticator_test.dart b/test/services/supabase/authentication/authenticator_test.dart index 6772f277..1f5a00eb 100644 --- a/test/services/supabase/authentication/authenticator_test.dart +++ b/test/services/supabase/authentication/authenticator_test.dart @@ -15,9 +15,10 @@ void main(){ registerFallbackValue(Uri()); registerFallbackValue(OAuthProvider.google); registerFallbackValue(UserAttributesFake()); + registerFallbackValue(OtpType.recovery); }); - group("authentication Tests", (){ + group("authentication Tests", () { setUp((){ client = SupabaseMock(); @@ -626,7 +627,7 @@ void main(){ test("User is logged in",() async{ - when(() => supabaseAuth.refreshSession()).thenAnswer((_) async => AuthResponse()); + when(() => supabaseAuth.currentSession).thenReturn(Session(accessToken: 'test', tokenType: 'test', user: User(id: '', appMetadata: {}, userMetadata: {}, aud: '', createdAt: ''))); final response = await authenticator.isLoggedIn(); expect(response,AuthenticationResponses.success); @@ -898,7 +899,7 @@ void main(){ ); final testUri = Uri.parse('https://example.com/callback?code=test123'); - await authenticator.handleOAuthRedirect(testUri); + await authenticator.getSessionFromURI(testUri); verify(() => supabaseAuth.getSessionFromUrl(testUri)).called(1); }); @@ -917,7 +918,7 @@ void main(){ when(() => supabaseAuth.getSessionFromUrl(any())).thenAnswer( (_) async => AuthSessionUrlResponse(session: Session(accessToken: "test", tokenType: "test", user: User(id: "1234", appMetadata: {}, userMetadata: {}, aud: "test", createdAt: "test")), redirectType: "test")); - await authenticator.handleOAuthRedirect(Uri()); + await authenticator.getSessionFromURI(Uri()); verify(() => client.auth.getSessionFromUrl(any())); }); @@ -946,6 +947,94 @@ void main(){ expect(result,'testemail@test.com'); }); - }); + test("Tests that code is verified correctly",() async { + when(() => supabaseAuth.verifyOTP( + email: any(named: 'email'), + token: any(named: 'token'), + type: any(named: 'type'), + )).thenAnswer((_) async => AuthResponse( + session: Session( + accessToken: "test", + tokenType: "test", + user: User( + id: '', + appMetadata: {}, + userMetadata: {}, + aud: '', + createdAt: '', + ), + ), + )); + + AuthenticationResponses testResponse = await authenticator.verifyCode(email: "test", code: "testCode"); + + expect(testResponse, AuthenticationResponses.success); + }); + + test("Tests that failure is returned when exception is thrown",() async { + when(() => supabaseAuth.verifyOTP( + email: any(named: 'email'), + token: any(named: 'token'), + type: any(named: 'type'), + )).thenThrow(Exception()); + + AuthenticationResponses testResponse = await authenticator.verifyCode(email: "test", code: "testCode"); + + expect(testResponse, AuthenticationResponses.failure); + }); + + test("Tests that exchange code for session runs correctly",() async { + when(() => supabaseAuth.exchangeCodeForSession(any())).thenAnswer((_) async => AuthSessionUrlResponse(session: Session(accessToken: "", tokenType: "", user: User(id: "", appMetadata: {}, userMetadata: {}, aud: "", createdAt: "")), redirectType:"")); + AuthenticationResponses response = await authenticator.exchangeCodeForSession("testCode"); + expect(response, AuthenticationResponses.success); + }); + + test("Tests that failure is sent with an exception",() async { + when(() => supabaseAuth.exchangeCodeForSession(any())).thenThrow(Exception()); + AuthenticationResponses response = await authenticator.exchangeCodeForSession("testCode"); + expect(response, AuthenticationResponses.failure); + }); + + test("Tests that update password runs correctly",() async { + + when(() => supabaseAuth.updateUser(any())).thenAnswer((_) async => UserResponse.fromJson({})); + + AuthenticationResponses response = await authenticator.updatePassword("password"); + + expect(response, AuthenticationResponses.success); + }); + + test("Tests that update password handles errors",() async { + + when(() => supabaseAuth.updateUser(any())).thenThrow(Exception()); + + AuthenticationResponses response = await authenticator.updatePassword("password"); + + expect(response, AuthenticationResponses.failure); + }); + + test("Reset password runs correctly",() async { + + when(() => supabaseAuth.resetPasswordForEmail(any())) + .thenAnswer((_) async {}); + + + AuthenticationResponses response = await authenticator.resetPassword("testEmail"); + + expect(response, AuthenticationResponses.success); + }); + + test("Reset password handles errors",() async { + + when(() => supabaseAuth.resetPasswordForEmail(any())) + .thenThrow(Exception()); + + + AuthenticationResponses response = await authenticator.resetPassword("testEmail"); + + expect(response, AuthenticationResponses.failure); + }); + + }); } \ No newline at end of file diff --git a/test/services/supabase/profile/profile_service_test.dart b/test/services/supabase/profile/profile_service_test.dart index 9e6fbea2..f0fef761 100644 --- a/test/services/supabase/profile/profile_service_test.dart +++ b/test/services/supabase/profile/profile_service_test.dart @@ -81,21 +81,20 @@ void main() { }); test("Tests that the logic was called correctly to update account balance", () async { - await profileHandler.updateBalanceById(47.20); - verify(() => supabaseMock.auth.currentUser).called(1); + await profileHandler.updateBalanceById('11111111-1111-1111-1111-111111111111', 47.20); verify(() => supabaseMock.from("profiles")).called(1); verify(() => queryBuilderMock.update({"balance": 47.20})).called(1); }); test("Tests that updateBalanceID catches Postgrest exception", () async { when(() => supabaseMock.from('profiles')).thenThrow(PostgrestException(message: "Test exception")); - await profileHandler.updateBalanceById(47.20); + await profileHandler.updateBalanceById('11111111-1111-1111-1111-111111111111', 47.20); //Test will fail if exception was not caught }); test("Tests that updateBalanceID catches unknown exception", () async { when(() => supabaseMock.from('profiles')).thenThrow(Exception("Test exception")); - await profileHandler.updateBalanceById(47.20); + await profileHandler.updateBalanceById('11111111-1111-1111-1111-111111111111', 47.20); //Test will fail if exception was not caught }); @@ -151,4 +150,20 @@ void main() { expect(() async => await profileHandler.updateName("testName"), throwsA(isA())); }); + test("Tests that the logic was called correctly to update account rewards", () async { + await profileHandler.updateRewardsById('11111111-1111-1111-1111-111111111111', 10.50); + verify(() => supabaseMock.from("profiles")).called(1); + verify(() => queryBuilderMock.update({"reward_tracker": 10.50})).called(1); + }); + + test("Tests that updateRewardsById catches Postgrest exception", () async { + when(() => supabaseMock.from('profiles')).thenThrow(PostgrestException(message: "Test exception")); + await profileHandler.updateRewardsById('11111111-1111-1111-1111-111111111111', 10.50); + }); + + test("Tests that updateRewardsById catches unknown exception", () async { + when(() => supabaseMock.from('profiles')).thenThrow(Exception("Test exception")); + await profileHandler.updateRewardsById('11111111-1111-1111-1111-111111111111', 10.50); + }); + } \ No newline at end of file diff --git a/test/services/supabase/transaction/transaction_service_test.dart b/test/services/supabase/transaction/transaction_service_test.dart index dfebf774..50f19a26 100644 --- a/test/services/supabase/transaction/transaction_service_test.dart +++ b/test/services/supabase/transaction/transaction_service_test.dart @@ -15,9 +15,19 @@ void main() { late RealtimeChannelMock channelMock; setUp(() { + final now = DateTime.now().toUtc(); + final fmt = (DateTime d) => d.toIso8601String(); + supabaseMock = SupabaseMock(); queryBuilderMock = QueryBuilderMock(); - fakeFilterBuilder = FakeFilterBuilder([{"amount": 2.75, "description": "machine", "created_at": "2025-11-02T16:24:51.685419+00:00", "requested_refund": true}, {"amount": 2.75, "description": "machine", "created_at": "2025-10-28T15:13:24.87605+00:00", "requested_refund": false}, {"amount": 2.75, "description": "machine", "created_at": "2025-10-28T14:27:54.429939+00:00", "requested_refund": true}, {"amount": 2.75, "description": "machine", "created_at": "2025-10-28T14:26:21.662999+00:00", "requested_refund": false}, {"amount": 2.75, "description": "machine", "created_at": "2025-10-27T18:06:40.987278+00:00", "requested_refund": false}, {"amount": 2.75, "description": "machine", "created_at": "2025-10-27T00:17:18.01511+00:00", "requested_refund": false}]); + fakeFilterBuilder = FakeFilterBuilder([ + {"id": 1, "amount": 2.75, "description": "machine", "created_at": fmt(now.subtract(Duration(days: 1))), "requested_refund": true}, + {"id": 2, "amount": 2.75, "description": "machine", "created_at": fmt(now.subtract(Duration(days: 2))), "requested_refund": false}, + {"id": 3, "amount": 2.75, "description": "machine", "created_at": fmt(now.subtract(Duration(days: 5))), "requested_refund": true}, + {"id": 4, "amount": 2.75, "description": "machine", "created_at": fmt(now.subtract(Duration(days: 7))), "requested_refund": false}, + {"id": 5, "amount": 2.75, "description": "machine", "created_at": fmt(now.subtract(Duration(days: 10))), "requested_refund": false}, + {"id": 6, "amount": 2.75, "description": "machine", "created_at": fmt(now.subtract(Duration(days: 13))), "requested_refund": false}, + ]); transactionHandler = SupabaseTransactionService(client: supabaseMock); supabaseAuth = GoTrueMock(); channelMock = RealtimeChannelMock(); @@ -69,7 +79,7 @@ void main() { test("Get refundable transaction history data",() async { final result = await transactionHandler.getRefundableTransactionsForUser(); - expect(result.length, 4); + expect(result.ids.length, 4); }); test("Tests if the user is null",() async{ diff --git a/test/widgets/custom_app_bar_test.dart b/test/widgets/custom_app_bar_test.dart index 87edae26..7df3c940 100644 --- a/test/widgets/custom_app_bar_test.dart +++ b/test/widgets/custom_app_bar_test.dart @@ -3,9 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - group("Custom App Bar Tests", () { - test('CustomAppBar instantiates correctly', () { const customAppBar = CustomAppBar(); expect(customAppBar, isA()); @@ -18,27 +16,21 @@ void main() { testWidgets('CustomAppBar builds an AppBar widget', (tester) async { await tester.pumpWidget( - MaterialApp( - home: Scaffold( - appBar: const CustomAppBar(), - ), - ), + MaterialApp(home: Scaffold(appBar: const CustomAppBar())), ); expect(find.byType(AppBar), findsOneWidget); }); - testWidgets('CustomAppBar uses theme primary color', (tester) async { - const testColor = Colors.green; + testWidgets('CustomAppBar has transparent background ', (tester) async { + const testColor = Colors.transparent; await tester.pumpWidget( MaterialApp( theme: ThemeData( colorScheme: const ColorScheme.light(primary: testColor), ), - home: Scaffold( - appBar: const CustomAppBar(), - ), + home: Scaffold(appBar: const CustomAppBar()), ), ); @@ -46,7 +38,9 @@ void main() { expect(appBar.backgroundColor, testColor); }); - testWidgets('CustomAppBar renders correctly inside Scaffold', (tester) async { + testWidgets('CustomAppBar renders correctly inside Scaffold', ( + tester, + ) async { await tester.pumpWidget( MaterialApp( home: Scaffold( diff --git a/test/widgets/dryer_controls_card_test.dart b/test/widgets/dryer_controls_card_test.dart new file mode 100644 index 00000000..74b53f80 --- /dev/null +++ b/test/widgets/dryer_controls_card_test.dart @@ -0,0 +1,209 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:clean_stream_laundry_app/widgets/dryer_controls_card.dart'; + +ThemeData _testTheme() => ThemeData.light(); + +Widget _wrap({required void Function(double, int) onChanged}) { + return MaterialApp( + theme: _testTheme(), + home: Scaffold( + body: DryerControlsCard(onChanged: onChanged), + ), + ); +} + +void main() { + group('DryerControlsCard', () { + + testWidgets('renders title text', (tester) async { + await tester.pumpWidget(_wrap(onChanged: (_, __) {})); + expect(find.text('Set Dry Time'), findsOneWidget); + }); + + testWidgets('renders default 30 min label', (tester) async { + await tester.pumpWidget(_wrap(onChanged: (_, __) {})); + expect(find.text('30 min'), findsOneWidget); + }); + + testWidgets('renders pricing hint text', (tester) async { + await tester.pumpWidget(_wrap(onChanged: (_, __) {})); + expect(find.text('\$0.25 per 5 minutes'), findsOneWidget); + }); + + testWidgets('renders min and max labels', (tester) async { + await tester.pumpWidget(_wrap(onChanged: (_, __) {})); + expect(find.text('5 min'), findsOneWidget); + expect(find.text('90 min'), findsOneWidget); + }); + + testWidgets('renders a Slider', (tester) async { + await tester.pumpWidget(_wrap(onChanged: (_, __) {})); + expect(find.byType(Slider), findsOneWidget); + }); + + testWidgets('renders as a Card with elevation', (tester) async { + await tester.pumpWidget(_wrap(onChanged: (_, __) {})); + final card = tester.widget(find.byType(Card)); + expect(card.elevation, 4); + }); + + + testWidgets('fires onChanged after first frame with default values', + (tester) async { + double? receivedPrice; + int? receivedMinutes; + + await tester.pumpWidget(_wrap(onChanged: (price, minutes) { + receivedPrice = price; + receivedMinutes = minutes; + })); + + await tester.pump(); + + expect(receivedMinutes, 30); + expect(receivedPrice, closeTo(1.50, 0.001)); + }); + + + group('price calculation', () { + Future dragToMinutes(WidgetTester tester, int minutes) async { + final slider = find.byType(Slider); + final sliderRect = tester.getRect(slider); + + const thumbPadding = 24.0; + final trackLeft = sliderRect.left + thumbPadding; + final trackWidth = sliderRect.width - thumbPadding * 2; + + final fraction = (minutes - 5) / 85.0; + final x = trackLeft + fraction * trackWidth; + + await tester.tapAt(Offset(x, sliderRect.center.dy)); + await tester.pump(); + } + + testWidgets('5 min → \$0.25', (tester) async { + double? price; + await tester.pumpWidget( + _wrap(onChanged: (p, _) => price = p)); + await dragToMinutes(tester, 5); + expect(price, closeTo(0.25, 0.001)); + }); + + testWidgets('30 min → \$1.50', (tester) async { + double? price; + await tester.pumpWidget( + _wrap(onChanged: (p, _) => price = p)); + await dragToMinutes(tester, 30); + expect(price, closeTo(1.50, 0.001)); + }); + + testWidgets('60 min → \$3.00', (tester) async { + double? price; + await tester.pumpWidget( + _wrap(onChanged: (p, _) => price = p)); + await dragToMinutes(tester, 60); + expect(price, closeTo(3.00, 0.001)); + }); + + testWidgets('90 min → \$4.50', (tester) async { + double? price; + await tester.pumpWidget( + _wrap(onChanged: (p, _) => price = p)); + await dragToMinutes(tester, 90); + expect(price, closeTo(4.50, 0.001)); + }); + }); + + + testWidgets('slider value snaps to multiples of 5', (tester) async { + final capturedMinutes = []; + + await tester.pumpWidget( + _wrap(onChanged: (_, minutes) => capturedMinutes.add(minutes))); + + final slider = find.byType(Slider); + final sliderBox = tester.renderObject(slider) as RenderBox; + final sliderWidth = sliderBox.size.width; + final center = tester.getCenter(slider); + + await tester.timedDragFrom( + Offset(center.dx - sliderWidth / 2, center.dy), + Offset(sliderWidth, 0), + const Duration(milliseconds: 500), + ); + await tester.pump(); + + for (final m in capturedMinutes) { + expect(m % 5, 0, + reason: '$m is not a multiple of 5'); + } + }); + + testWidgets('minute label updates when slider moves', (tester) async { + await tester.pumpWidget(_wrap(onChanged: (_, __) {})); + + final slider = find.byType(Slider); + final sliderBox = tester.renderObject(slider) as RenderBox; + final sliderWidth = sliderBox.size.width; + final center = tester.getCenter(slider); + + await tester.dragFrom( + Offset(center.dx - sliderWidth / 2, center.dy), + Offset(sliderWidth, 0), + ); + await tester.pump(); + + expect(find.text('90 min'), findsWidgets); + }); + + + testWidgets('onChanged is called when slider moves', (tester) async { + int callCount = 0; + await tester.pumpWidget( + _wrap(onChanged: (_, __) => callCount++)); + + await tester.pump(); + callCount = 0; + + final slider = find.byType(Slider); + final sliderBox = tester.renderObject(slider) as RenderBox; + final sliderWidth = sliderBox.size.width; + final center = tester.getCenter(slider); + + await tester.dragFrom( + Offset(center.dx - sliderWidth / 2, center.dy), + Offset(sliderWidth * 0.5, 0), + ); + await tester.pump(); + + expect(callCount, greaterThan(0)); + }); + + testWidgets('onChanged price and minutes are consistent', (tester) async { + double? lastPrice; + int? lastMinutes; + + await tester.pumpWidget(_wrap(onChanged: (price, minutes) { + lastPrice = price; + lastMinutes = minutes; + })); + + final slider = find.byType(Slider); + final sliderBox = tester.renderObject(slider) as RenderBox; + final sliderWidth = sliderBox.size.width; + final center = tester.getCenter(slider); + + await tester.dragFrom( + Offset(center.dx - sliderWidth / 2, center.dy), + Offset(sliderWidth * 0.35, 0), // somewhere in the middle + ); + await tester.pump(); + + expect(lastMinutes, isNotNull); + expect(lastPrice, isNotNull); + final expectedPrice = (lastMinutes! / 5) * 0.25; + expect(lastPrice, closeTo(expectedPrice, 0.001)); + }); + }); +} \ No newline at end of file diff --git a/test/widgets/navigation_bar_test.dart b/test/widgets/navigation_bar_test.dart index c676d9f1..dc465dcf 100644 --- a/test/widgets/navigation_bar_test.dart +++ b/test/widgets/navigation_bar_test.dart @@ -22,6 +22,20 @@ void main() { bottomNavigationBar: const NavBar(), ), ), + GoRoute( + path: '/scanner', + builder: (_, __) => Scaffold( + body: const Text('Scanner Page'), + bottomNavigationBar: const NavBar(), + ), + ), + GoRoute( + path: '/paymentPage', + builder: (_, __) => Scaffold( + body: const Text('Payment Page'), + bottomNavigationBar: const NavBar(), + ), + ), GoRoute( path: '/loyalty', builder: (_, __) => Scaffold( @@ -39,9 +53,7 @@ void main() { ], ); - return MaterialApp.router( - routerConfig: router, - ); + return MaterialApp.router(routerConfig: router); } group('NavBar Widget Tests', () { @@ -104,8 +116,30 @@ void main() { await tester.pumpWidget(wrapWithRouter('/loyalty')); await tester.pumpAndSettle(); - final bottomNav = tester.widget(find.byType(BottomNavigationBar)); + final bottomNav = tester.widget( + find.byType(BottomNavigationBar), + ); expect(bottomNav.currentIndex, 2); }); + + testWidgets('Scanner route highlights Start tab', (tester) async { + await tester.pumpWidget(wrapWithRouter('/scanner')); + await tester.pumpAndSettle(); + + final bottomNav = tester.widget( + find.byType(BottomNavigationBar), + ); + expect(bottomNav.currentIndex, 1); + }); + + testWidgets('Payment route highlights Start tab', (tester) async { + await tester.pumpWidget(wrapWithRouter('/paymentPage?machineId=abc')); + await tester.pumpAndSettle(); + + final bottomNav = tester.widget( + find.byType(BottomNavigationBar), + ); + expect(bottomNav.currentIndex, 1); + }); }); -} \ No newline at end of file +} diff --git a/test/widgets/large_button_test.dart b/test/widgets/qr_button.dart similarity index 85% rename from test/widgets/large_button_test.dart rename to test/widgets/qr_button.dart index 4c7509ec..a6d547e7 100644 --- a/test/widgets/large_button_test.dart +++ b/test/widgets/qr_button.dart @@ -1,23 +1,22 @@ -import 'package:clean_stream_laundry_app/widgets/large_button.dart'; +import 'package:clean_stream_laundry_app/widgets/qr_button.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { group("Large Button Tests", () { - test('Large Button instantiates correctly', () { - const largeButton = LargeButton( + const largeButton = QRButton( headLineText: "Test Headline", descriptionText: "Test Description", icon: Icons.shield, ); - expect(largeButton, isA()); + expect(largeButton, isA()); }); testWidgets('Tests that the correct headline is found', (tester) async { await tester.pumpWidget( const MaterialApp( - home: LargeButton( + home: QRButton( headLineText: "Test Headline", descriptionText: "Test Description", icon: Icons.shield, @@ -31,7 +30,7 @@ void main() { testWidgets('Tests that the correct description is found', (tester) async { await tester.pumpWidget( const MaterialApp( - home: LargeButton( + home: QRButton( headLineText: "Test Headline", descriptionText: "Test Description", icon: Icons.shield, @@ -45,7 +44,7 @@ void main() { testWidgets('Tests that the correct icon is found', (tester) async { await tester.pumpWidget( const MaterialApp( - home: LargeButton( + home: QRButton( headLineText: "Test Headline", descriptionText: "Test Description", icon: Icons.shield, @@ -56,4 +55,4 @@ void main() { expect(find.byIcon(Icons.shield), findsOneWidget); }); }); -} \ No newline at end of file +} diff --git a/test/widgets/show_searching_test.dart b/test/widgets/show_searching_test.dart new file mode 100644 index 00000000..78762f21 --- /dev/null +++ b/test/widgets/show_searching_test.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:clean_stream_laundry_app/widgets/show_searching.dart'; + +void main() { + + testWidgets('showSearchingDialog displays dialog', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + return Scaffold( + body: Center( + child: ElevatedButton( + onPressed: () => showSearchingDialog( + context, + () {}, + ), + child: const Text('Open Dialog'), + ), + ), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Open Dialog')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.byType(Dialog), findsOneWidget); + expect(find.text('Finding Nearby Doors...'), findsOneWidget); + expect( + find.text('Please wait while we search for the nearest door.'), + findsOneWidget, + ); + expect(find.text('Cancel'), findsOneWidget); + }, + ); + + testWidgets('Cancel button sets cancelSearch and closes dialog', + (WidgetTester tester) async { + cancelSearch = false; + bool cancelCalled = false; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + return Scaffold( + body: Center( + child: ElevatedButton( + onPressed: () => showSearchingDialog( + context, + () { + cancelCalled = true; + }, + ), + child: const Text('Open Dialog'), + ), + ), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Open Dialog')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + await tester.tap(find.text('Cancel')); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 100)); + + expect(cancelSearch, true); + expect(cancelCalled, true); + expect(find.byType(Dialog), findsNothing); + }, + ); +} \ No newline at end of file diff --git a/test/widgets/transaction_search_sheet_test.dart b/test/widgets/transaction_search_sheet_test.dart new file mode 100644 index 00000000..1a6da772 --- /dev/null +++ b/test/widgets/transaction_search_sheet_test.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:clean_stream_laundry_app/widgets/transactions_search_sheet.dart'; + +void main() { + const transactions = [ + '01/10/2026 - Coffee - \$5.00', + '02/14/2026 - Dinner - \$45.00', + '03/01/2026 - Books - \$30.00', + ]; + + Widget buildTestWidget() { + return MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () { + showModalBottomSheet( + context: context, + builder: (_) => const TransactionSearchSheet( + transactions: transactions, + ), + ); + }, + child: const Text('Open'), + ), + ), + ), + ); + } + + group('TransactionSearchSheet Widget Tests', () { + testWidgets('renders all transactions initially', + (WidgetTester tester) async { + await tester.pumpWidget(buildTestWidget()); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.text(transactions[0]), findsOneWidget); + expect(find.text(transactions[1]), findsOneWidget); + expect(find.text(transactions[2]), findsOneWidget); + }); + + testWidgets('filters transactions based on search input', + (WidgetTester tester) async { + await tester.pumpWidget(buildTestWidget()); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), '02/14'); + await tester.pumpAndSettle(); + + expect(find.text(transactions[1]), findsOneWidget); + expect(find.text(transactions[0]), findsNothing); + expect(find.text(transactions[2]), findsNothing); + }); + + testWidgets('is case insensitive', + (WidgetTester tester) async { + await tester.pumpWidget(buildTestWidget()); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), 'coffee'); + await tester.pumpAndSettle(); + + expect(find.text(transactions[0]), findsOneWidget); + }); + + testWidgets('returns selected transaction when tapped', + (WidgetTester tester) async { + String? selected; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () async { + selected = await showModalBottomSheet( + context: context, + builder: (_) => const TransactionSearchSheet( + transactions: transactions, + ), + ); + }, + child: const Text('Open'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + await tester.tap(find.text(transactions[1])); + await tester.pumpAndSettle(); + + expect(selected, transactions[1]); + }); + }); +} diff --git a/test/widgets/washer_controls_card_test.dart b/test/widgets/washer_controls_card_test.dart new file mode 100644 index 00000000..e4d75f8d --- /dev/null +++ b/test/widgets/washer_controls_card_test.dart @@ -0,0 +1,126 @@ +import 'package:clean_stream_laundry_app/widgets/washer_controls_card.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late double? receivedCost; + + Widget createTestWidget() { + return MaterialApp( + home: Scaffold( + body: WasherControlsCard( + onCycleChanged: (cost) => receivedCost = cost, + ), + ), + ); + } + + setUp(() { + receivedCost = null; + }); + + testWidgets('renders all four washer cycle buttons', (tester) async { + await tester.pumpWidget(createTestWidget()); + + expect(find.text('Hot Heavy'), findsOneWidget); + expect(find.text('Hot Normal'), findsOneWidget); + expect(find.text('Cold Heavy'), findsOneWidget); + expect(find.text('Cold Normal'), findsOneWidget); + }); + + testWidgets('selecting Hot Heavy updates state and cost', (tester) async { + await tester.pumpWidget(createTestWidget()); + + await tester.tap(find.text('Hot Heavy')); + await tester.pump(); + + expect(receivedCost, 0.5); + + final button = tester.widget(find.widgetWithText(ElevatedButton, 'Hot Heavy')); + expect((button.style?.backgroundColor?.resolve({})) , Colors.green); + }); + + testWidgets('selecting Hot Normal updates state and cost', (tester) async { + await tester.pumpWidget(createTestWidget()); + + await tester.tap(find.text('Hot Normal')); + await tester.pump(); + + expect(receivedCost, 0.25); + + final button = tester.widget(find.widgetWithText(ElevatedButton, 'Hot Normal')); + expect((button.style?.backgroundColor?.resolve({})), Colors.green); + }); + + testWidgets('selecting Cold Heavy updates state and cost', (tester) async { + await tester.pumpWidget(createTestWidget()); + + await tester.tap(find.text('Cold Heavy')); + await tester.pump(); + + expect(receivedCost, 0.25); + + final button = tester.widget(find.widgetWithText(ElevatedButton, 'Cold Heavy')); + expect((button.style?.backgroundColor?.resolve({})), Colors.green); + }); + + testWidgets('selecting Cold Normal updates state and cost', (tester) async { + await tester.pumpWidget(createTestWidget()); + + await tester.tap(find.text('Cold Normal')); + await tester.pump(); + + expect(receivedCost, 0); + + final button = tester.widget(find.widgetWithText(ElevatedButton, 'Cold Normal')); + expect((button.style?.backgroundColor?.resolve({})), Colors.green); + }); + + testWidgets('only one button is selected at a time', (tester) async { + await tester.pumpWidget(createTestWidget()); + + // Select Hot Heavy + await tester.tap(find.text('Hot Heavy')); + await tester.pump(); + + ElevatedButton hotHeavy = tester.widget(find.widgetWithText(ElevatedButton, 'Hot Heavy')); + ElevatedButton hotNormal = tester.widget(find.widgetWithText(ElevatedButton, 'Hot Normal')); + + expect(hotHeavy.style?.backgroundColor?.resolve({}), Colors.green); + expect(hotNormal.style?.backgroundColor?.resolve({}), isNot(Colors.green)); + + // Select Hot Normal + await tester.tap(find.text('Hot Normal')); + await tester.pump(); + + hotHeavy = tester.widget(find.widgetWithText(ElevatedButton, 'Hot Heavy')); + hotNormal = tester.widget(find.widgetWithText(ElevatedButton, 'Hot Normal')); + + expect(hotHeavy.style?.backgroundColor?.resolve({}), isNot(Colors.green)); + expect(hotNormal.style?.backgroundColor?.resolve({}), Colors.green); + }); + + testWidgets('callback fires exactly once per tap', (tester) async { + int callCount = 0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: WasherControlsCard( + onCycleChanged: (_) => callCount++, + ), + ), + ), + ); + + await tester.tap(find.text('Hot Heavy')); + await tester.pump(); + + expect(callCount, 1); + + await tester.tap(find.text('Cold Normal')); + await tester.pump(); + + expect(callCount, 2); + }); +} \ No newline at end of file diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 1b9d289f..3e035bd8 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,12 +7,15 @@ #include "generated_plugin_registrant.h" #include +#include #include #include void RegisterPlugins(flutter::PluginRegistry* registry) { AppLinksPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("AppLinksPluginCApi")); + GeolocatorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("GeolocatorWindows")); PermissionHandlerWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index ad82af28..d49f9206 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST app_links + geolocator_windows permission_handler_windows url_launcher_windows )