diff --git a/.claudeignore b/.claudeignore index fd98048..1938815 100644 --- a/.claudeignore +++ b/.claudeignore @@ -10,8 +10,6 @@ qa-screens/ redesign_stitch/ stitch_petsphere_app_redesign/ *.xml -*.html -*.jsx # ========================================== # 2. FLUTTER BUILD & CACHE diff --git a/.cursor/hooks/state/continual-learning-index.json b/.cursor/hooks/state/continual-learning-index.json deleted file mode 100644 index 5c07df3..0000000 --- a/.cursor/hooks/state/continual-learning-index.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "version": 1, - "files": { - "C:\\Users\\syedr\\.cursor\\projects\\g-GitHub-petfolio\\agent-transcripts\\ce1b2c8d-81a4-4639-90f8-3721e6c503a7\\ce1b2c8d-81a4-4639-90f8-3721e6c503a7.jsonl": 1779035133681, - "C:\\Users\\syedr\\.cursor\\projects\\g-GitHub-petfolio\\agent-transcripts\\94643488-4659-4d0e-9545-49a79437f020\\94643488-4659-4d0e-9545-49a79437f020.jsonl": 1778967578761, - "C:\\Users\\syedr\\.cursor\\projects\\g-GitHub-petfolio\\agent-transcripts\\36e3088e-0d5c-45e5-b1ba-d1054303de94\\36e3088e-0d5c-45e5-b1ba-d1054303de94.jsonl": 1779039991962, - "C:\\Users\\syedr\\.cursor\\projects\\g-GitHub-petfolio\\agent-transcripts\\bced3497-a066-4257-b439-2b857e493c49\\bced3497-a066-4257-b439-2b857e493c49.jsonl": 1778888950579, - "C:\\Users\\syedr\\.cursor\\projects\\g-GitHub-petfolio\\agent-transcripts\\80eedde5-7184-424c-b917-a680f1ff70a2\\80eedde5-7184-424c-b917-a680f1ff70a2.jsonl": 1778892745538, - "C:\\Users\\syedr\\.cursor\\projects\\g-GitHub-petfolio\\agent-transcripts\\87c931dd-27e3-40aa-bdc6-3274831f9214\\87c931dd-27e3-40aa-bdc6-3274831f9214.jsonl": 1778958857308, - "C:\\Users\\syedr\\.cursor\\projects\\g-GitHub-petfolio\\agent-transcripts\\60e1a481-cb4c-4ac2-aa03-bfae7cefa5fe\\60e1a481-cb4c-4ac2-aa03-bfae7cefa5fe.jsonl": 1778881486521, - "C:\\Users\\syedr\\.cursor\\projects\\g-GitHub-petfolio\\agent-transcripts\\bb710a65-baaf-4764-9eae-dacedace3006\\bb710a65-baaf-4764-9eae-dacedace3006.jsonl": 1778786794129, - "C:\\Users\\syedr\\.cursor\\projects\\g-GitHub-petfolio\\agent-transcripts\\274c391d-7a36-49d5-87be-4df8b522bd16\\274c391d-7a36-49d5-87be-4df8b522bd16.jsonl": 1778800364415, - "C:\\Users\\syedr\\.cursor\\projects\\g-GitHub-petfolio\\agent-transcripts\\194eaf83-5b12-43e4-b1ea-318bbf90c26c\\194eaf83-5b12-43e4-b1ea-318bbf90c26c.jsonl": 1778954732395, - "C:\\Users\\syedr\\.cursor\\projects\\g-GitHub-petfolio\\agent-transcripts\\25b68db3-c204-4def-aead-73ff3500c794\\25b68db3-c204-4def-aead-73ff3500c794.jsonl": 1778876459233, - "C:\\Users\\syedr\\.cursor\\projects\\g-GitHub-petfolio\\agent-transcripts\\8c5433a9-d387-4e9b-ba3a-1e1e006c37bf\\8c5433a9-d387-4e9b-ba3a-1e1e006c37bf.jsonl": 1778856406418, - "C:\\Users\\syedr\\.cursor\\projects\\g-GitHub-petfolio\\agent-transcripts\\e6552f8a-c6c0-4b71-b118-14f55640ae1f\\e6552f8a-c6c0-4b71-b118-14f55640ae1f.jsonl": 1778776581760, - "C:\\Users\\syedr\\.cursor\\projects\\g-GitHub-petfolio\\agent-transcripts\\16628f4f-057e-4274-96e0-ec9ad3d893be\\16628f4f-057e-4274-96e0-ec9ad3d893be.jsonl": 1778882210597, - "C:\\Users\\syedr\\.cursor\\projects\\g-GitHub-petfolio\\agent-transcripts\\0148be8a-b886-48f4-bdd3-67d076c8182c\\0148be8a-b886-48f4-bdd3-67d076c8182c.jsonl": 1778792789903, - "C:\\Users\\syedr\\.cursor\\projects\\g-GitHub-petfolio\\agent-transcripts\\7abfd4c7-d454-4cf6-a391-333321a013ad\\7abfd4c7-d454-4cf6-a391-333321a013ad.jsonl": 1779058557683, - "C:\\Users\\syedr\\.cursor\\projects\\g-GitHub-petfolio\\agent-transcripts\\5acfed98-7cbf-4420-ac3d-091356b8e44f\\5acfed98-7cbf-4420-ac3d-091356b8e44f.jsonl": 1778871687683, - "C:\\Users\\syedr\\.cursor\\projects\\g-GitHub-petfolio\\agent-transcripts\\d7bd1b6a-f748-47f6-87ac-953246413613\\d7bd1b6a-f748-47f6-87ac-953246413613.jsonl": 1778799809267, - "C:\\Users\\syedr\\.cursor\\projects\\g-GitHub-petfolio\\agent-transcripts\\2d86f00f-6bfb-451f-928c-ff0fea1f79d2\\2d86f00f-6bfb-451f-928c-ff0fea1f79d2.jsonl": 1779034182814, - "C:\\Users\\syedr\\.cursor\\projects\\g-GitHub-petfolio\\agent-transcripts\\6460028d-51b8-402d-8b25-5fbcae4596f2\\6460028d-51b8-402d-8b25-5fbcae4596f2.jsonl": 1778961242286, - "C:\\Users\\syedr\\.cursor\\projects\\g-GitHub-petfolio\\agent-transcripts\\88c2a075-4e77-4d24-81f3-b7ba5b774ac4\\88c2a075-4e77-4d24-81f3-b7ba5b774ac4.jsonl": 1778891774309, - "C:\\Users\\syedr\\.cursor\\projects\\g-GitHub-petfolio\\agent-transcripts\\9ebdd98e-260d-475c-999c-dd06afa742cd\\9ebdd98e-260d-475c-999c-dd06afa742cd.jsonl": 1778802413316, - "C:\\Users\\syedr\\.cursor\\projects\\g-GitHub-petfolio\\agent-transcripts\\5bead4b5-9471-461d-ab9a-957f4c1c2490\\5bead4b5-9471-461d-ab9a-957f4c1c2490.jsonl": 1778892105848, - "C:\\Users\\syedr\\.cursor\\projects\\g-GitHub-petfolio\\agent-transcripts\\dab4e7c9-f531-4cb9-a593-62d5c029e54b\\dab4e7c9-f531-4cb9-a593-62d5c029e54b.jsonl": 1778879297617, - "C:\\Users\\syedr\\.cursor\\projects\\g-GitHub-petfolio\\agent-transcripts\\8ea6431f-f572-411a-98e3-0dc5552c5a51\\8ea6431f-f572-411a-98e3-0dc5552c5a51.jsonl": 1778783272217, - "C:\\Users\\syedr\\.cursor\\projects\\g-GitHub-petfolio\\agent-transcripts\\babe61d3-90ce-4696-be96-37e1e4e0f971\\babe61d3-90ce-4696-be96-37e1e4e0f971.jsonl": 1778891057278, - "C:\\Users\\syedr\\.cursor\\projects\\g-GitHub-petfolio\\agent-transcripts\\09fd1bb0-4e5e-4e5d-a591-a22fac645ab3\\09fd1bb0-4e5e-4e5d-a591-a22fac645ab3.jsonl": 1778873095891 - } -} diff --git a/.cursor/hooks/state/continual-learning.json b/.cursor/hooks/state/continual-learning.json deleted file mode 100644 index 34fa82f..0000000 --- a/.cursor/hooks/state/continual-learning.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "version": 1, - "lastRunAtMs": 1779058550389, - "turnsSinceLastRun": 6, - "lastTranscriptMtimeMs": 1779058549884.8618, - "lastProcessedGenerationId": "2559ad56-7755-4a6c-a9c0-f183a58b10cb", - "trialStartedAtMs": null -} diff --git a/.cursor/settings.json b/.cursor/settings.json new file mode 100644 index 0000000..67c10f0 --- /dev/null +++ b/.cursor/settings.json @@ -0,0 +1,16 @@ +{ + "plugins": { + "stripe": { + "enabled": true + }, + "supabase": { + "enabled": true + }, + "modern-web-guidance": { + "enabled": true + }, + "firebase": { + "enabled": true + } + } +} diff --git a/.cursorignore b/.cursorignore index 631c1be..1938815 100644 --- a/.cursorignore +++ b/.cursorignore @@ -1,27 +1,18 @@ # ========================================== -# 1. SECURITY & SECRETS (CRITICAL) +# 1. PETFOLIO SPECIFIC NOISE (CRITICAL TO IGNORE) # ========================================== - -*.key -*.pem -credentials.json -supabase/.temp/ - -# ========================================== -# 2. PETFOLIO SPECIFIC NOISE -# ========================================== -# Prevents AI from reading massive UI mockups & QA logs -claude_design/ +# These folders contain hundreds of HTML, JSX, XML, and PNG +# files from UI audits and design stitches. They will flood +# the context window and cause severe token bloat. +docs/resolved-deprecated/claude_design/ docs/logs/ qa-screens/ redesign_stitch/ stitch_petsphere_app_redesign/ *.xml -*.html -*.jsx # ========================================== -# 3. FLUTTER BUILD & CACHE +# 2. FLUTTER BUILD & CACHE # ========================================== build/ .dart_tool/ @@ -29,68 +20,96 @@ build/ .pub/ # ========================================== -# 4. CODE GENERATION +# 3. CODE GENERATION # ========================================== -# Forces AI to rely on your models, not the generated outputs +# Ignore generated files so the AI doesn't try to edit them directly *.g.dart *.freezed.dart *.mocks.dart *.part.dart # ========================================== -# 5. NATIVE PLATFORM DIRECTORIES -# ========================================== -android/.gradle -android/.kotlin -android/gradle-wrapper.jar -android/captures/ -android/gradlew -android/gradlew.bat -android/local.properties -android/GeneratedPluginRegistrant.java -android/.cxx/ - -ios/**/dgph -ios/*.mode1v3 -ios/*.mode2v3 -ios/*.moved-aside -ios/*.pbxuser -ios/*.perspectivev3 -ios/**/*sync/ -ios/.sconsign.dblite -ios/.tags* -ios/**/.vagrant/ -ios/**/DerivedData/ -ios/Icon? -ios/**/Pods/ -ios/**/.symlinks/ -ios/profile -ios/xcuserdata -ios/**/.generated/ -ios/Flutter/App.framework -ios/Flutter/Flutter.framework -ios/Flutter/Flutter.podspec -ios/Flutter/Generated.xcconfig -ios/Flutter/ephemeral/ -ios/Flutter/app.flx -ios/Flutter/app.zip -ios/Flutter/flutter_assets/ -ios/Flutter/flutter_export_environment.sh -ios/ServiceDefinitions.json -ios/Runner/GeneratedPluginRegistrant.* - - - -web/ -macos/ -windows/ -linux/ - -# ========================================== -# 6. ASSETS & MEDIA -# ========================================== -assets/ -google_fonts/ +# 4. NATIVE PLATFORM DIRECTORIES +# ========================================== +# Unless you are specifically asking the agent to write Kotlin/Swift, +# keep these ignored so it focuses on the `lib/` directory. + +# Android related +**/android/**/gradle-wrapper.jar +.gradle/ +**/android/captures/ +**/android/gradlew +**/android/gradlew.bat +**/android/local.properties +**/android/**/GeneratedPluginRegistrant.java +**/android/key.properties +*.jks + +# iOS/XCode related +**/ios/**/*.mode1v3 +**/ios/**/*.mode2v3 +**/ios/**/*.moved-aside +**/ios/**/*.pbxuser +**/ios/**/*.perspectivev3 +**/ios/**/*sync/ +**/ios/**/.sconsign.dblite +**/ios/**/.tags* +**/ios/**/.vagrant/ +**/ios/**/DerivedData/ +**/ios/**/Icon? +**/ios/**/Pods/ +**/ios/**/.symlinks/ +**/ios/**/profile +**/ios/**/xcuserdata +**/ios/.generated/ +**/ios/Flutter/.last_build_id +**/ios/Flutter/App.framework +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Flutter.podspec +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/ephemeral +**/ios/Flutter/app.flx +**/ios/Flutter/app.zip +**/ios/Flutter/flutter_assets/ +**/ios/Flutter/flutter_export_environment.sh +**/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* + +# macOS +**/Flutter/ephemeral/ +**/Pods/ +**/macos/Flutter/GeneratedPluginRegistrant.swift +**/macos/Flutter/ephemeral +**/xcuserdata/ + +# Windows +**/windows/flutter/generated_plugin_registrant.cc +**/windows/flutter/generated_plugin_registrant.h +**/windows/flutter/generated_plugins.cmake + +# Linux +**/linux/flutter/generated_plugin_registrant.cc +**/linux/flutter/generated_plugin_registrant.h +**/linux/flutter/generated_plugins.cmake + +# Coverage +coverage/ + +# Symbols +app.*.symbols + +# Exceptions to above rules. +!**/ios/**/default.mode1v3 +!**/ios/**/default.mode2v3 +!**/ios/**/default.pbxuser +!**/ios/**/default.perspectivev3 +!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages +!/dev/ci/**/Gemfile.lock + +# ========================================== +# 5. ASSETS & MEDIA +# ========================================== + *.png *.jpg *.jpeg @@ -101,13 +120,23 @@ google_fonts/ *.zip # ========================================== -# 7. IDE & ENVIRONMENT +# 6. SUPABASE LOCAL STATE +# ========================================== +supabase/.temp/ + +# ========================================== +# 7. DEPENDENCY LOCKS +# ========================================== +pubspec.lock +skills-lock.json + +# ========================================== +# 8. IDE & ENVIRONMENT # ========================================== .idea/ .vscode/ .claude/ -.cursor/hooks/ + +.cursor/ *.iml .metadata -pubspec.lock -skills-lock.json \ No newline at end of file diff --git a/.env.example b/.env.example index e31df98..8ec36b2 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,18 @@ SUPABASE_URL= SUPABASE_ANON_KEY= STRIPE_PUBLISHABLE_KEY= -NVIDIA_API_KEY= \ No newline at end of file +NVIDIA_API_KEY= + +FIREBASE_VAPID_KEY= +FIREBASE_PROJECT_ID=petfolio-v1 +FIREBASE_MESSAGING_SENDER_ID=86798095066 +FIREBASE_WEB_API_KEY=AIzaSyDG5_rufMTdwV9X2wc7M5YNyEkWwXN8tGM +FIREBASE_WEB_APP_ID=1:86798095066:web:61021d3c9119434a68cbe3 +FIREBASE_AUTH_DOMAIN=petfolio-v1.firebaseapp.com +FIREBASE_STORAGE_BUCKET=petfolio-v1.firebasestorage.app + +FCM_DISPATCH_SECRET= + +# GitHub Actions (deploy-web.yml): gh secret set -f .env -R CodeStorm-Hub/petfolio +# Also set VERCEL_TOKEN manually: gh secret set VERCEL_TOKEN -R CodeStorm-Hub/petfolio +# Supabase hosted checkout (production): npx supabase secrets set PUBLIC_APP_ORIGIN=https://petfolio.live --project-ref jqyjvhwlcqcsuwcqgcwf diff --git a/.firebaserc b/.firebaserc new file mode 100644 index 0000000..9dfe387 --- /dev/null +++ b/.firebaserc @@ -0,0 +1,5 @@ +{ + "projects": { + "default": "petfolio-v1" + } +} diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml index 5f6a910..e7e94b4 100644 --- a/.github/workflows/deploy-web.yml +++ b/.github/workflows/deploy-web.yml @@ -1,14 +1,18 @@ -name: Deploy Flutter Web to Vercel +name: Deploy Flutter Web to Vercel (production) on: push: branches: [main] - pull_request: - branches: [main] + workflow_dispatch: + +concurrency: + group: deploy-web-production + cancel-in-progress: true jobs: build-and-deploy: runs-on: ubuntu-latest + timeout-minutes: 30 env: VERCEL_ORG_ID: team_lC8aTJK0XiU9qDfaHeTfCJs6 @@ -21,33 +25,55 @@ jobs: - name: Setup Flutter uses: subosito/flutter-action@v2 with: - flutter-version: '3.x' + flutter-version: '3.44.0' channel: stable cache: true + - name: Enable Flutter web + run: flutter config --enable-web + - name: Install dependencies run: flutter pub get + - name: Materialize CI .env from secrets + run: | + umask 077 + { + echo "SUPABASE_URL=${{ secrets.SUPABASE_URL }}" + echo "SUPABASE_ANON_KEY=${{ secrets.SUPABASE_ANON_KEY }}" + echo "STRIPE_PUBLISHABLE_KEY=${{ secrets.STRIPE_PUBLISHABLE_KEY }}" + echo "NVIDIA_API_KEY=${{ secrets.NVIDIA_API_KEY }}" + echo "FIREBASE_VAPID_KEY=${{ secrets.FIREBASE_VAPID_KEY }}" + echo "FIREBASE_PROJECT_ID=${{ secrets.FIREBASE_PROJECT_ID }}" + echo "FIREBASE_MESSAGING_SENDER_ID=${{ secrets.FIREBASE_MESSAGING_SENDER_ID }}" + echo "FIREBASE_WEB_API_KEY=${{ secrets.FIREBASE_WEB_API_KEY }}" + echo "FIREBASE_WEB_APP_ID=${{ secrets.FIREBASE_WEB_APP_ID }}" + echo "FIREBASE_AUTH_DOMAIN=${{ secrets.FIREBASE_AUTH_DOMAIN }}" + echo "FIREBASE_STORAGE_BUCKET=${{ secrets.FIREBASE_STORAGE_BUCKET }}" + } > .env + + - name: Sync Firebase web config for service worker + run: dart run tool/sync_firebase_web_config.dart + - name: Build Flutter Web (release) run: | flutter build web --release \ --dart-define=SUPABASE_URL=${{ secrets.SUPABASE_URL }} \ --dart-define=SUPABASE_ANON_KEY=${{ secrets.SUPABASE_ANON_KEY }} \ --dart-define=STRIPE_PUBLISHABLE_KEY=${{ secrets.STRIPE_PUBLISHABLE_KEY }} \ - --dart-define=NVIDIA_API_KEY=${{ secrets.NVIDIA_API_KEY }} + --dart-define=NVIDIA_API_KEY=${{ secrets.NVIDIA_API_KEY }} \ + --dart-define=FIREBASE_VAPID_KEY=${{ secrets.FIREBASE_VAPID_KEY }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' - - name: Vercel build (preview) - if: github.event_name == 'pull_request' - run: npx vercel build --yes --token=${{ secrets.VERCEL_TOKEN }} + - name: Install Vercel CLI + run: npm install -g vercel@latest - name: Vercel build (production) - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - run: npx vercel build --prod --yes --token=${{ secrets.VERCEL_TOKEN }} - - - name: Deploy preview to Vercel (PRs) - if: github.event_name == 'pull_request' - run: npx vercel deploy --prebuilt --yes --token=${{ secrets.VERCEL_TOKEN }} + run: vercel build --prod --yes --token=${{ secrets.VERCEL_TOKEN }} - - name: Deploy to Vercel production (main only) - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - run: npx vercel deploy --prebuilt --prod --yes --token=${{ secrets.VERCEL_TOKEN }} + - name: Deploy to Vercel production + run: vercel deploy --prebuilt --prod --yes --token=${{ secrets.VERCEL_TOKEN }} diff --git a/.gitignore b/.gitignore index 681da64..48c63c6 100644 --- a/.gitignore +++ b/.gitignore @@ -119,6 +119,9 @@ app.*.symbols !/dev/ci/**/Gemfile.lock .env .env.local +*firebase-adminsdk*.json +petfolio-v1-firebase-adminsdk*.json +supabase/.secrets.fcm.env # Generated seeding output from scripts/generate_inserts.dart — not a versioned migration inserts.sql diff --git a/AGENTS.md b/AGENTS.md index 9d73dbb..cbe3d13 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,11 +12,13 @@ You are an expert Flutter and Supabase developer. For the PetFolio project, you ## Learned User Preferences - When applying Supabase schema to the hosted project, prefer the Supabase MCP migration path when available; if using the Supabase CLI, prefix commands with `npx` (for example `npx supabase db push`). -- After completing a distinct care, marketplace, or backend phase, keep `progress.md` updated and use `/remember` to persist high-signal context before starting the next phase. +- After completing a distinct care, marketplace, backend, or PWA/web phase, keep `progress.md` updated and use `/remember` to persist high-signal context before starting the next phase. +- Web/PWA work follows phased rollout in `PWA_WEB_AUDIT.md`; do not change Android APK behavior—gate web-only paths with `kIsWeb` and `lib/core/platform/`. - Avoid importing `router.dart` from screens that `router.dart` already imports; use literal paths or query strings for deep links to prevent circular imports. - For optimistic UI and one-off actions (care task toggles, Stripe seller onboarding), surface failures with `AppSnackBar.showError`; do not set long-lived providers (e.g. `myShopProvider`) to `AsyncValue.error` for transient failures. - When a subagent result is already visible in the UI, avoid re-summarizing it unless multi-task synthesis is needed; a short third-person completion note is enough, and avoid repeating the same confirmation every turn. - When asked to save audits, reviews, or comparable findings, write them as Markdown under the repository root unless a different path is specified. +- For hosted FCM setup, use `tool/set_fcm_supabase_secrets.ps1` and `firebase-instructions.md`; `FIREBASE_SERVICE_ACCOUNT_JSON` in Supabase must be minified valid JSON and `FCM_DISPATCH_SECRET` must match `private.fcm_internal_config`. ## Learned Workspace Facts @@ -30,3 +32,5 @@ You are an expert Flutter and Supabase developer. For the PetFolio project, you - Social screen header Messages navigates to `/matching`. - PostGIS matching: `/matching/inbox`, `/matching/chat/:threadId`; `swipes`/`matches` and `matching_discovery_candidates` RPC (`MatchingRepository`); `chat_threads.mutual_match_id` + `ensure_chat_thread_for_match` (legacy `participant_*`/`match_request_id` possible). RPC needs `is_discoverable IS TRUE` and non-null `pets.location`; `petHasLocation` uses `.not('location', 'is', null)`. `mutualMatchInsertStreamProvider` + `MatchCelebrationOverlay`; `matchPreferenceControllerProvider` + `discoveryCandidatesControllerProvider` (replenish when stack drops below 5, ~450ms debounced `invalidateSelf()` — do not `ref.watch` prefs in `build()` on slider drags). Location: `location_service.dart`; denied/services-off empty state on `MatchingScreen`. Riverpod 3: generated notifiers omit type params (`extends _$Foo`); use `AsyncValue.value`, not `.valueOrNull`. - Multi-vendor marketplace (`docs/claude-handoff.md`): cart `itemsByShop` + per-shop `startCheckoutForShop`; **Discover Shops** via `shopListProvider` → `/shop/:id`; seller routes `/seller`, `/seller/setup`, `/seller/onboarding`, products/orders. `ShopRepository.startOnboarding` calls Edge Function `stripe-onboard-vendor` with `functions.invoke(..., body: {'shopId'})` (not `.rpc()`), reads `accountLinkUrl`; platform Stripe account needs **Connect** enabled. +- **FCM push:** `FcmService` + `user_fcm_devices`; Edge `send-fcm-notification` / `_shared/fcm_send.ts`; DB triggers on `chat_messages`, `notifications`, `matches`, `marketplace_orders`; outbox `fcm_push_outbox` + `process-fcm-outbox`; care `care_web_reminders` + `process-care-fcm-reminders`. Tray UI: `petfolio_push` channel, `fcm_push_display.dart`, tap → `FcmMessageRouter`. Setup: `firebase-instructions.md`, `tool/set_fcm_supabase_secrets.ps1`. +- PWA/web (`PWA_WEB_AUDIT.md`, Vercel deploy): Phases 1–2 done—`web/index.html` shell fixes; `lib/core/platform/` (`PlatformNotifications` IO local vs web `care_web_reminders`, `useStripeHostedCheckout` + hosted `create-payment-intent` session on web, `pickGalleryImage`, web FCM via `FIREBASE_VAPID_KEY` + `FcmService.syncToken` / `firebase-messaging-sw.js`). Matching on web uses pet profile location (`petMatchLocationProvider`), not device GPS. diff --git a/PWA_WEB_AUDIT.md b/PWA_WEB_AUDIT.md new file mode 100644 index 0000000..5552028 --- /dev/null +++ b/PWA_WEB_AUDIT.md @@ -0,0 +1,353 @@ +# PetFolio Web / PWA Audit (Android unchanged) + +**Scope:** `web/` directory, Vercel deploy, iOS Safari + Add to Home Screen PWA, `lib/` platform gaps. +**Date:** 2026-06-05 (revised — full `web/` review with `index.html` accessible) +**Goal:** Parity with Android where feasible; identify freeze / dead-touch causes and platform gaps. + +--- + +## Executive summary + +The **`web/` layer is small but intentional** (custom splash, iOS meta, Stripe.js, install banner). It follows modern Flutter bootstrapping (`flutter_bootstrap.js` + `flutter-first-frame`). **`lib/` remains mobile-first** with only **three `kIsWeb` branches** — that gap still drives missing location/notifications and most feature parity issues. + +**Highest-probability causes of “frozen screen / buttons not clickable” on iOS PWA:** + +| # | Cause | Where | +|---|--------|--------| +| 1 | Flutter iOS PWA engine bugs (ghost keyboard viewport after `TextField`) | Engine — [flutter#111896](https://github.com/flutter/flutter/issues/111896), [flutter#115829](https://github.com/flutter/flutter/issues/115829) | +| 2 | `#pwa-loading` never removed if `flutter-first-frame` never fires | `web/index.html` | +| 3 | **Double safe-area** — CSS `body` padding + Flutter `MediaQuery.padding` | `web/index.html` + `AppShell` floating nav | +| 4 | **`pwa_banner.js`** fixed bottom sheet (`z-index: 99999`) in iOS Safari (pre-install) | Overlaps Flutter bottom nav hit zone | +| 5 | Full-screen Flutter overlays + bottom sheets + keyboard | `lib/` UI | +| 6 | Heavy `CachedNetworkImage` → Safari reload on real devices | Social / matching | + +--- + +## `web/` directory — full review + +### Inventory + +| File | Lines | Purpose | +|------|-------|---------| +| `web/index.html` | 123 | App shell, PWA meta, splash, Stripe, bootstrap | +| `web/manifest.json` | 39 | Web app manifest | +| `web/pwa_banner.js` | 69 | iOS Safari “Add to Home Screen” promo | +| `web/icons/*`, `web/favicon.png` | (Flutter defaults) | Referenced by HTML/manifest; standard `flutter create` assets | + +There is **no** custom `web/flutter_bootstrap.js` — the project uses the **build-generated** bootstrap (correct for Flutter 3.22+). + +--- + +### `web/index.html` — line-by-line assessment + +#### Document head — good + +| Lines | Content | Verdict | +|-------|---------|---------| +| 4 | `` | Correct; substituted at `flutter build web` | +| 10–11 | `viewport-fit=cover` | Required for notched iOS PWA | +| 13–14 | Meta description (UTF-8 em dash) | OK | +| 17–20 | `theme-color` light/dark | Matches brand | +| 23–26 | `mobile-web-app-capable` + `apple-mobile-web-app-capable` | Redundant but harmless | +| 25 | `black-translucent` status bar | Good for edge-to-edge; pairs with safe-area handling | +| 29–30 | `apple-touch-icon` 192 + 512 | OK; Apple often prefers 180×180 — consider adding | +| 33 | `favicon.png` | OK if present in `web/` after `flutter create` | +| 36 | `manifest.json` link | OK | + +#### Stripe.js — risk + +```38:39:web/index.html + + +``` + +| Issue | Severity | Detail | +|--------|----------|--------| +| **Synchronous third-party script in ``** | **High** | Blocks HTML parsing until `js.stripe.com` responds. Slow networks delay first paint and can delay `flutter-first-frame` → splash feels “stuck”. | +| **No `async` / `defer`** | Medium | Flutter Stripe may need Stripe global before payment; for **startup**, defer until first checkout or load with `defer` + readiness check in Dart. | +| **No SRI / fallback** | Low | Stripe CDN outage = blank or hung boot if combined with other failures | + +**Recommendation:** Load Stripe only when entering checkout (dynamic ` +``` + +#### Script load order (actual) + +```mermaid +sequenceDiagram + participant HTML as index.html + participant Stripe as js.stripe.com + participant FB as flutter_bootstrap.js + participant Flutter as Flutter engine + participant Banner as pwa_banner.js + + HTML->>Stripe: sync script (blocks parse) + HTML->>FB: async load + FB->>Flutter: initialize + runApp + Flutter-->>HTML: flutter-first-frame + HTML->>HTML: remove #pwa-loading + HTML->>Banner: defer after parse + Note over Banner: iOS Safari only, z-index 99999 +``` + +--- + +### `web/pwa_banner.js` — full review + +| Behavior | Detail | +|----------|--------| +| **Target** | iPhone/iPad/iPod, **not** `navigator.standalone`, not dismissed | +| **UI** | Fixed bottom sheet, `z-index: 99999`, ~120px+ tall | +| **Copy** | “works offline too” — **misleading** (no custom offline cache strategy) | +| **Dismiss** | `localStorage.pwa_banner_dismissed` | + +| Issue | Severity | Detail | +|--------|----------|--------| +| **Covers Flutter bottom nav** | **High** (Safari pre-install) | Before A2HS, users browse in Safari with banner + Flutter `AppShell` floating nav — **same bottom band**. Taps may hit DOM banner instead of Flutter canvas. | +| **`z-index: 99999` above everything** | High | Intentional for promo, but blocks app chrome until dismissed | +| **“Offline” claim** | Medium | Legal/UX trust issue unless you implement offline | +| **No `safe-area-inset-bottom` on banner** | Medium | On notched phones, banner padding may clash with home indicator | +| **`role="banner"`** | Low | Competes semantically with app header | + +**Recommendations:** + +1. After `flutter-first-frame`, inject banner **inside** a known layout OR show install CTA **inside Flutter** on `kIsWeb` + iOS user-agent. +2. Add `padding-bottom: calc(32px + env(safe-area-inset-bottom))` to `#pwa-banner`. +3. Change copy to: “Add to Home Screen for app-like experience” (drop offline unless implemented). +4. In standalone mode you already skip banner — good. + +--- + +### `web/manifest.json` — full review + +| Field | Value | Verdict | +|-------|-------|---------| +| `display` | `standalone` | Correct for PWA | +| `orientation` | `portrait-primary` | Matches mobile app; web desktop gets letterboxing | +| `start_url` / `scope` | `/` | OK with Vercel SPA rewrite | +| `theme_color` / `background_color` | Brand cream / orange | Matches splash | +| `icons` | 192, 512, maskable | OK | +| `screenshots` | `[]` | Empty — weak install UI on Chromium | +| `id` | absent | Consider `"id": "/"` per spec for update stability | +| `display_override` | absent | Optional: `["standalone", "browser"]` | + +**Missing for iOS (not in manifest — use HTML):** + +- Apple does not use `manifest.json` for A2HS; relies on `apple-mobile-web-app-*` in `index.html` — **you have those**. + +**Recommendations (P2):** + +- Add `screenshots` for Play-style install on Android/desktop Chrome. +- Add `shortcuts` for `/care`, `/matching`, `/marketplace`. + +--- + +### `web/` vs Vercel / CI + +| Config | Finding | +|--------|---------| +| `vercel.json` | Rewrites all non-file routes → `index.html` — **GoRouter deep links work on refresh** | +| `vercel.json` headers | COEP/COOP **only on `*.wasm`** — document root not cross-origin isolated unless you build with `--wasm` and add page-level headers | +| `deploy-web.yml` | `flutter build web --release` — **no** `--wasm`, **no** `--web-renderer` pin, `flutter-version: 3.x` loose | +| Custom `web/` files | Copied into `build/web` on build — **index.html / manifest / pwa_banner.js ship as edited** | + +--- + +## PWA infrastructure (summary) + +| Asset | Role | +|--------|------| +| `web/index.html` | Meta, splash, Stripe, bootstrap | +| `web/manifest.json` | Installability (Android/desktop) | +| `web/pwa_banner.js` | iOS Safari install funnel | +| `vercel.json` | SPA + SW cache + WASM headers | +| `.github/workflows/deploy-web.yml` | Build + Vercel deploy | + +| Gap | Severity | +|-----|----------| +| No `lib/core/platform/web/` | High | +| Stripe sync in `` | High | +| Body safe-area double-count | **P0** | +| No splash error path | **P0** | +| COEP partial / no `--wasm` in CI | Medium | +| Offline claim in banner | Medium | + +--- + +## Startup & `lib/main.dart` + +```dart +if (!kIsWeb) await NotificationService.instance.initialize(); +Stripe.publishableKey = ... +await Stripe.instance.applySettings(); +await Supabase.initialize(...); +``` + +| Behavior | Web impact | +|----------|------------| +| Notifications init skipped | OK, but care still schedules reminders (below) | +| Stripe + Supabase before `runApp` | Any await failure → **no `flutter-first-frame`** → splash stuck | +| `GoogleFonts.config.allowRuntimeFetching = false` | Good for web | + +--- + +## Platform capability matrix (Android vs Web PWA) + +| Capability | Android | Web / iOS PWA | Recommendation | +|------------|---------|---------------|----------------| +| Care reminders | Local notifications | Init skipped; **still scheduled** in `pet_care_repository.dart` | `kIsWeb` guard + Web Push | +| GPS | `geolocator` | Always `unavailable` | `navigator.geolocation` bridge | +| Matching | GPS + profile | **Always location-blocked UI** | Web geo + allow profile `location` | +| Checkout | Payment Sheet | `flutter_stripe_web` | Checkout redirect on web/iOS | +| Photos | `image_picker` | Weak on web | `file_picker` / file input bridge | +| AI care | NVIDIA direct | Edge Function on web | Keep | + +--- + +## `lib/` UI issues (amplified on iOS PWA) + +### `AppShell` — stacked chrome + +```93:111:lib/core/widgets/app_shell.dart + body: Stack( + children: [ + Positioned.fill(child: child), + Positioned(top: 0, child: AppShellHeader(...)), + Positioned(bottom: ..., child: _FloatingNav(...)), + ], + ), +``` + +- Bottom nav sits in the **iOS PWA dead-zone** after keyboard use. +- Combined with **`body` CSS safe-area** → systematic hit-test skew. + +### Other hotspots + +- `PrimaryPillButton` — `Listener` only (no `Material` ink). +- `MatchingScreen` — `unavailable` ⇒ blocked; `IgnorePointer` when celebration overlay active. +- Many `TextField`s, bottom sheets, large images. + +--- + +## `kIsWeb` inventory (entire `lib/`) + +| File | Usage | +|------|--------| +| `main.dart` | Skip notification init | +| `location_service.dart` | Disable geolocation | +| `care_recommendation_service.dart` | Edge Function on web | + +--- + +## Care notifications bug (web) + +`pet_care_repository.dart` → `_scheduleNotificationIfNeeded` calls `NotificationService` without `kIsWeb` guard. + +--- + +## Recommended roadmap (web/PWA only) + +### Phase 0 — Diagnose + +1. iPhone: Safari (with banner) vs standalone PWA. +2. Web Inspector: Stripe load time, `flutter-first-frame`, errors. +3. Confirm splash clears; test after login TextField + keyboard dismiss. + +### Phase 1 — `web/` + P0 (highest ROI) — **DONE (2026-06-05)** + +1. ~~**Remove `body` safe-area CSS**~~ — done in `web/index.html`. +2. ~~**Splash timeout + error message**~~ — done in `web/index.html`. +3. ~~**Defer Stripe.js**~~ — `defer` in HTML; `ensureStripeReady()` at checkout on web. +4. ~~**Fix `pwa_banner`**~~ — safe-area padding + copy fix. +5. ~~**`kIsWeb` guards**~~ — notifications, geolocator web, matching profile-location fallback. +6. ~~**AppShell web layout**~~ — `bottomNavigationBar` on web. + +### Phase 2 — Parity — **DONE (2026-06-05)** + +1. **`lib/core/platform/`** — `PlatformNotifications` (io local / web `care_web_reminders`), `media_picker` (gallery via `image_picker` on web), `platform_payments`, `web_push_registration`, `platform_services.dart` barrel. +2. **Stripe Checkout redirect on web** — `create-payment-intent` `checkout_mode`; `OrderRepository.createCheckoutSession`; `CheckoutNotifier` opens hosted URL; resume polling via `WebCheckoutResumeListener`; webhook `checkout.session.expired`. +3. **Web care reminders** — `care_web_reminders` table + web notification scheduling (server dispatch still requires VAPID/cron — see deploy notes). +4. **Web Push registration** — `user_web_push_subscriptions`, edge function `register-web-push-subscription`, `web/push_register.js`, Care `WebPushEnableBanner` (needs `WEB_PUSH_VAPID_PUBLIC_KEY` dart-define). +5. **Media uploads** — `pickGalleryImage()` wired in profile, social, shop, KYC, medical vault. + +### Phase 3 — Polish — **DONE (2026-06-05)** + +1. **`web/manifest.json`** — `id`, `screenshots` (narrow/wide), `shortcuts` for `/care`, `/matching`, `/marketplace`. +2. **`web/screenshots/` + `web/splash/`** — branded PNGs; `index.html` `apple-touch-startup-image` for common iPhone sizes + fallback. +3. **`lib/core/platform/web_image_cache.dart`** — `networkImageMemCacheWidth` / `networkImageMaxDiskCacheWidth` with web Safari caps; wired on feed, stories, matching, marketplace, `PetAvatar`. + +--- + +## Testing checklist (iOS PWA) + +- [ ] Splash dismisses < 30s on 4G; timeout message if not +- [ ] iOS Safari **with** install banner — bottom nav tappable after dismiss +- [ ] Standalone PWA — all 5 tabs +- [ ] TextField → keyboard dismiss → bottom controls work +- [ ] Matching deck (after geo fix) +- [ ] Marketplace checkout (web Stripe path) +- [ ] Hard refresh / SW update after deploy + +--- + +## References + +- [Flutter web initialization](https://docs.flutter.dev/platform-integration/web/initialization) +- [Flutter WASM / COEP](https://docs.flutter.dev/platform-integration/web/wasm) +- [flutter#111896](https://github.com/flutter/flutter/issues/111896) · [flutter#115829](https://github.com/flutter/flutter/issues/115829) +- [flutter_stripe_web](https://pub.dev/packages/flutter_stripe) + +--- + +*Android APK paths excluded. `web/` reviewed in full including `index.html`.* diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 6e691be..f319404 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,7 +1,7 @@ plugins { id("com.android.application") id("kotlin-android") - // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("com.google.gms.google-services") id("dev.flutter.flutter-gradle-plugin") } diff --git a/android/app/google-services.json b/android/app/google-services.json new file mode 100644 index 0000000..7827342 --- /dev/null +++ b/android/app/google-services.json @@ -0,0 +1,29 @@ +{ + "project_info": { + "project_number": "86798095066", + "project_id": "petfolio-v1", + "storage_bucket": "petfolio-v1.firebasestorage.app" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:86798095066:android:5b5d6008f7ab957f68cbe3", + "android_client_info": { + "package_name": "com.example.petfolio" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyDW3jiVahlDqTey6aj0zsj9Z-Dj-kvamgE" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index ad674c3..9b165ce 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -43,6 +43,9 @@ + firebaseMessagingBackgroundHandler(RemoteMessage message) async { + if (Firebase.apps.isEmpty) { + await Firebase.initializeApp( + options: DefaultFirebaseOptions.currentPlatform, + ); + } + await NotificationService.instance.initializeForBackgroundMessaging(); + await showFcmAsLocalNotification(message); + if (kDebugMode) { + debugPrint('[FCM] background: ${message.messageId} ${message.data}'); + } +} diff --git a/lib/core/firebase/fcm_lifecycle.dart b/lib/core/firebase/fcm_lifecycle.dart new file mode 100644 index 0000000..29b1cf5 --- /dev/null +++ b/lib/core/firebase/fcm_lifecycle.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../features/auth/presentation/controllers/auth_controller.dart'; +import 'fcm_service.dart'; + +class FcmLifecycle extends ConsumerStatefulWidget { + const FcmLifecycle({required this.child, super.key}); + + final Widget child; + + @override + ConsumerState createState() => _FcmLifecycleState(); +} + +class _FcmLifecycleState extends ConsumerState { + @override + Widget build(BuildContext context) { + ref.listen(authStateProvider, (previous, next) { + next.whenData((state) async { + if (state.session != null) { + await FcmService.instance.syncToken(); + } else { + await FcmService.instance.clearTokenForSignOut(); + } + }); + }); + + final router = GoRouter.of(context); + FcmService.instance.updateRouter(router); + return widget.child; + } +} diff --git a/lib/core/firebase/fcm_message_router.dart b/lib/core/firebase/fcm_message_router.dart new file mode 100644 index 0000000..ed706e4 --- /dev/null +++ b/lib/core/firebase/fcm_message_router.dart @@ -0,0 +1,73 @@ +import 'package:go_router/go_router.dart'; + +class FcmMessageRouter { + FcmMessageRouter._(); + + static String? routeFromData(Map data) { + final route = data['route'] as String?; + if (route != null && route.startsWith('/')) return route; + + final type = data['type'] as String?; + if (type == null) return null; + + switch (type) { + case 'care_reminder': + return '/care'; + case 'match': + return '/matching/inbox'; + case 'chat_message': + final threadId = data['thread_id'] as String?; + if (threadId != null && threadId.isNotEmpty) { + return '/matching/chat/$threadId'; + } + return '/matching/inbox'; + case 'like': + case 'comment': + final postId = data['post_id'] as String?; + if (postId != null && postId.isNotEmpty) { + return '/social/post/$postId'; + } + return '/social/notifications'; + case 'follow': + return '/social/notifications'; + case 'kyc_approved': + return '/seller'; + case 'kyc_rejected': + return '/seller/kyc'; + case 'order': + final orderId = data['order_id'] as String?; + if (orderId != null && orderId.isNotEmpty) { + return '/profile/orders/$orderId'; + } + return '/profile/orders'; + case 'seller_order': + final sellerOrderId = data['order_id'] as String?; + if (sellerOrderId != null && sellerOrderId.isNotEmpty) { + return '/seller/orders/$sellerOrderId'; + } + return '/seller/orders'; + default: + return null; + } + } + + static bool usePushForPath(String path) { + return path.startsWith('/matching/chat/') || + path == '/matching/inbox' || + path.startsWith('/social/post/') || + path == '/social/notifications' || + path.startsWith('/profile/orders/') || + path.startsWith('/seller/orders/') || + path == '/seller/kyc'; + } + + static void navigate(GoRouter router, Map data) { + final path = routeFromData(data); + if (path == null) return; + if (usePushForPath(path)) { + router.push(path); + } else { + router.go(path); + } + } +} diff --git a/lib/core/firebase/fcm_push_display.dart b/lib/core/firebase/fcm_push_display.dart new file mode 100644 index 0000000..507ae21 --- /dev/null +++ b/lib/core/firebase/fcm_push_display.dart @@ -0,0 +1,19 @@ +import 'package:firebase_messaging/firebase_messaging.dart'; + +import '../services/notification_service.dart'; + +Future showFcmAsLocalNotification(RemoteMessage message) async { + final title = message.notification?.title ?? message.data['title'] as String?; + final body = message.notification?.body ?? message.data['body'] as String?; + final data = Map.from(message.data); + final id = message.messageId?.hashCode ?? + message.sentTime?.millisecondsSinceEpoch ?? + DateTime.now().millisecondsSinceEpoch; + + await NotificationService.instance.showPushNotification( + id: id.abs() % 1000000, + title: title, + body: body, + data: data, + ); +} diff --git a/lib/core/firebase/fcm_service.dart b/lib/core/firebase/fcm_service.dart new file mode 100644 index 0000000..8696d5d --- /dev/null +++ b/lib/core/firebase/fcm_service.dart @@ -0,0 +1,137 @@ +import 'dart:async'; + +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/foundation.dart'; +import 'package:go_router/go_router.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import 'fcm_message_router.dart'; +import 'fcm_push_display.dart'; +import 'fcm_token_repository.dart'; +import '../../firebase_options.dart'; +import 'firebase_env.dart'; + +class FcmService { + FcmService._(); + static final instance = FcmService._(); + + StreamSubscription? _tokenRefreshSub; + + FcmTokenRepository get _tokenRepo => + FcmTokenRepository(Supabase.instance.client); + GoRouter? _router; + + bool get isAvailable => Firebase.apps.isNotEmpty; + + Future initialize({GoRouter? router}) async { + if (Firebase.apps.isEmpty) { + await Firebase.initializeApp( + options: DefaultFirebaseOptions.currentPlatform, + ); + } + + _router = router; + + final messaging = FirebaseMessaging.instance; + await messaging.setForegroundNotificationPresentationOptions( + alert: true, + badge: true, + sound: true, + ); + + await _requestPermission(messaging); + await syncToken(); + + _tokenRefreshSub?.cancel(); + _tokenRefreshSub = messaging.onTokenRefresh.listen((token) async { + await _tokenRepo.upsertToken( + token: token, + platform: FirebaseEnv.platformLabel, + ); + }); + + FirebaseMessaging.onMessage.listen((message) { + _handleForeground(message); + }); + FirebaseMessaging.onMessageOpenedApp.listen(_handleOpened); + final initial = await messaging.getInitialMessage(); + if (initial != null) _handleOpened(initial); + } + + Future _requestPermission(FirebaseMessaging messaging) async { + final settings = await messaging.requestPermission( + alert: true, + badge: true, + sound: true, + provisional: false, + ); + if (kDebugMode) { + debugPrint('[FCM] permission: ${settings.authorizationStatus}'); + } + } + + Future syncToken() async { + if (!isAvailable) return false; + if (!FirebaseEnv.canRequestWebToken) return false; + final session = Supabase.instance.client.auth.currentSession; + if (session == null) return false; + + try { + final messaging = FirebaseMessaging.instance; + String? token; + if (kIsWeb) { + token = await messaging.getToken(vapidKey: FirebaseEnv.vapidKey); + } else { + token = await messaging.getToken(); + } + if (token == null || token.isEmpty) return false; + + await _tokenRepo.upsertToken( + token: token, + platform: FirebaseEnv.platformLabel, + ); + if (kDebugMode) { + debugPrint('[FCM] token synced (${FirebaseEnv.platformLabel})'); + } + return true; + } catch (e) { + if (kDebugMode) debugPrint('[FCM] syncToken failed: $e'); + return false; + } + } + + Future clearTokenForSignOut() async { + if (!isAvailable) return; + try { + final token = await FirebaseMessaging.instance.getToken(); + if (token != null) { + await _tokenRepo.deleteToken(token); + await FirebaseMessaging.instance.deleteToken(); + } + } catch (_) {} + } + + Future _handleForeground(RemoteMessage message) async { + await showFcmAsLocalNotification(message); + } + + void _handleOpened(RemoteMessage message) { + final router = _router; + if (router == null) return; + FcmMessageRouter.navigate(router, message.data); + } + + void updateRouter(GoRouter router) => _router = router; + + void handleNotificationTap(Map data) { + final router = _router; + if (router == null) return; + FcmMessageRouter.navigate(router, data); + } + + Future dispose() async { + await _tokenRefreshSub?.cancel(); + _tokenRefreshSub = null; + } +} diff --git a/lib/core/firebase/fcm_token_repository.dart b/lib/core/firebase/fcm_token_repository.dart new file mode 100644 index 0000000..7e7e923 --- /dev/null +++ b/lib/core/firebase/fcm_token_repository.dart @@ -0,0 +1,36 @@ +import 'package:supabase_flutter/supabase_flutter.dart'; + +class FcmTokenRepository { + FcmTokenRepository(this._client); + + final SupabaseClient _client; + + Future upsertToken({ + required String token, + required String platform, + }) async { + final userId = _client.auth.currentUser?.id; + if (userId == null) return; + + await _client.from('user_fcm_devices').upsert( + { + 'user_id': userId, + 'fcm_token': token, + 'platform': platform, + 'updated_at': DateTime.now().toUtc().toIso8601String(), + }, + onConflict: 'fcm_token', + ); + } + + Future deleteToken(String token) async { + final userId = _client.auth.currentUser?.id; + if (userId == null) return; + + await _client + .from('user_fcm_devices') + .delete() + .eq('user_id', userId) + .eq('fcm_token', token); + } +} diff --git a/lib/core/firebase/firebase_env.dart b/lib/core/firebase/firebase_env.dart new file mode 100644 index 0000000..3c3ace9 --- /dev/null +++ b/lib/core/firebase/firebase_env.dart @@ -0,0 +1,28 @@ +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter/foundation.dart' show kIsWeb, defaultTargetPlatform, TargetPlatform; + +import '../../firebase_options.dart'; + +class FirebaseEnv { + FirebaseEnv._(); + + static const vapidKey = String.fromEnvironment('FIREBASE_VAPID_KEY'); + + static bool get isConfigured => true; + + static FirebaseOptions get options => DefaultFirebaseOptions.currentPlatform; + + static bool get canRequestWebToken => !kIsWeb || vapidKey.isNotEmpty; + + static String get platformLabel { + if (kIsWeb) return 'web'; + switch (defaultTargetPlatform) { + case TargetPlatform.android: + return 'android'; + case TargetPlatform.iOS: + return 'ios'; + default: + return 'unknown'; + } + } +} diff --git a/lib/core/navigation/route_overlay_dismissal.dart b/lib/core/navigation/route_overlay_dismissal.dart new file mode 100644 index 0000000..79c793b --- /dev/null +++ b/lib/core/navigation/route_overlay_dismissal.dart @@ -0,0 +1,7 @@ +import 'package:flutter/material.dart'; + +void dismissRootOverlayRoutes(GlobalKey rootNavigatorKey) { + final navigator = rootNavigatorKey.currentState; + if (navigator == null) return; + navigator.popUntil((route) => route is! PopupRoute); +} diff --git a/lib/core/platform/care_fcm_reminder_sync.dart b/lib/core/platform/care_fcm_reminder_sync.dart new file mode 100644 index 0000000..12ded62 --- /dev/null +++ b/lib/core/platform/care_fcm_reminder_sync.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +Future upsertCareFcmReminder({ + required String taskId, + required String title, + required TimeOfDay tod, + required bool repeating, +}) async { + final userId = Supabase.instance.client.auth.currentUser?.id; + if (userId == null) return; + + final now = DateTime.now(); + var scheduled = DateTime(now.year, now.month, now.day, tod.hour, tod.minute); + if (scheduled.isBefore(now)) { + scheduled = scheduled.add(const Duration(days: 1)); + } + + await Supabase.instance.client.from('care_web_reminders').upsert( + { + 'user_id': userId, + 'task_id': taskId, + 'title': title, + 'remind_at': scheduled.toUtc().toIso8601String(), + 'repeating': repeating, + 'fcm_sent_at': null, + 'updated_at': DateTime.now().toUtc().toIso8601String(), + }, + onConflict: 'user_id,task_id', + ); +} + +Future deleteCareFcmReminder(String taskId) async { + final userId = Supabase.instance.client.auth.currentUser?.id; + if (userId == null) return; + + await Supabase.instance.client + .from('care_web_reminders') + .delete() + .eq('user_id', userId) + .eq('task_id', taskId); +} diff --git a/lib/core/platform/media_picker.dart b/lib/core/platform/media_picker.dart new file mode 100644 index 0000000..c00b546 --- /dev/null +++ b/lib/core/platform/media_picker.dart @@ -0,0 +1 @@ +export 'media_picker_io.dart' if (dart.library.html) 'media_picker_web.dart'; diff --git a/lib/core/platform/media_picker_io.dart b/lib/core/platform/media_picker_io.dart new file mode 100644 index 0000000..816784d --- /dev/null +++ b/lib/core/platform/media_picker_io.dart @@ -0,0 +1,12 @@ +import 'package:image_picker/image_picker.dart'; + +Future pickGalleryImage({ + int? maxWidth, + int? imageQuality, +}) { + return ImagePicker().pickImage( + source: ImageSource.gallery, + maxWidth: maxWidth?.toDouble(), + imageQuality: imageQuality, + ); +} diff --git a/lib/core/platform/media_picker_web.dart b/lib/core/platform/media_picker_web.dart new file mode 100644 index 0000000..816784d --- /dev/null +++ b/lib/core/platform/media_picker_web.dart @@ -0,0 +1,12 @@ +import 'package:image_picker/image_picker.dart'; + +Future pickGalleryImage({ + int? maxWidth, + int? imageQuality, +}) { + return ImagePicker().pickImage( + source: ImageSource.gallery, + maxWidth: maxWidth?.toDouble(), + imageQuality: imageQuality, + ); +} diff --git a/lib/core/platform/platform_location.dart b/lib/core/platform/platform_location.dart new file mode 100644 index 0000000..c94245f --- /dev/null +++ b/lib/core/platform/platform_location.dart @@ -0,0 +1,3 @@ +export '../services/location_service.dart'; +export '../services/location_providers.dart'; +export '../services/lat_lng.dart'; diff --git a/lib/core/platform/platform_notifications.dart b/lib/core/platform/platform_notifications.dart new file mode 100644 index 0000000..5805d94 --- /dev/null +++ b/lib/core/platform/platform_notifications.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; + +import 'platform_notifications_io.dart' + if (dart.library.html) 'platform_notifications_web.dart' as impl; + +abstract class PlatformNotifications { + static PlatformNotifications get instance => impl.platformNotifications; + + Future initialize(); + + Future scheduleTaskReminder({ + required String taskId, + required String title, + required TimeOfDay tod, + required bool repeating, + }); + + Future cancelForTask(String taskId); + + Future cancelAll(); +} diff --git a/lib/core/platform/platform_notifications_io.dart b/lib/core/platform/platform_notifications_io.dart new file mode 100644 index 0000000..8d59184 --- /dev/null +++ b/lib/core/platform/platform_notifications_io.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import '../services/notification_service.dart'; +import 'care_fcm_reminder_sync.dart'; +import 'platform_notifications.dart'; + +final PlatformNotifications platformNotifications = _IoPlatformNotifications(); + +class _IoPlatformNotifications implements PlatformNotifications { + final _native = NotificationService.instance; + + @override + Future initialize() async {} + + @override + Future scheduleTaskReminder({ + required String taskId, + required String title, + required TimeOfDay tod, + required bool repeating, + }) async { + await _native.scheduleTaskReminder( + taskId: taskId, + title: title, + tod: tod, + repeating: repeating, + ); + await upsertCareFcmReminder( + taskId: taskId, + title: title, + tod: tod, + repeating: repeating, + ); + } + + @override + Future cancelForTask(String taskId) async { + await _native.cancelForTask(taskId); + await deleteCareFcmReminder(taskId); + } + + @override + Future cancelAll() async { + await _native.cancelAll(); + final userId = Supabase.instance.client.auth.currentUser?.id; + if (userId != null) { + await Supabase.instance.client + .from('care_web_reminders') + .delete() + .eq('user_id', userId); + } + } +} diff --git a/lib/core/platform/platform_notifications_web.dart b/lib/core/platform/platform_notifications_web.dart new file mode 100644 index 0000000..11279f7 --- /dev/null +++ b/lib/core/platform/platform_notifications_web.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import 'care_fcm_reminder_sync.dart'; +import 'platform_notifications.dart'; + +final PlatformNotifications platformNotifications = _WebPlatformNotifications(); + +class _WebPlatformNotifications implements PlatformNotifications { + SupabaseClient get _client => Supabase.instance.client; + + @override + Future initialize() async {} + + @override + Future scheduleTaskReminder({ + required String taskId, + required String title, + required TimeOfDay tod, + required bool repeating, + }) => upsertCareFcmReminder( + taskId: taskId, + title: title, + tod: tod, + repeating: repeating, + ); + + @override + Future cancelForTask(String taskId) => deleteCareFcmReminder(taskId); + + @override + Future cancelAll() async { + final userId = _client.auth.currentUser?.id; + if (userId == null) return; + await _client.from('care_web_reminders').delete().eq('user_id', userId); + } +} diff --git a/lib/core/platform/platform_payments.dart b/lib/core/platform/platform_payments.dart new file mode 100644 index 0000000..831e9c2 --- /dev/null +++ b/lib/core/platform/platform_payments.dart @@ -0,0 +1,3 @@ +import 'package:flutter/foundation.dart'; + +bool get useStripeHostedCheckout => kIsWeb; diff --git a/lib/core/platform/platform_services.dart b/lib/core/platform/platform_services.dart new file mode 100644 index 0000000..c287367 --- /dev/null +++ b/lib/core/platform/platform_services.dart @@ -0,0 +1,6 @@ +export 'media_picker.dart'; +export 'platform_location.dart'; +export 'platform_notifications.dart'; +export 'platform_payments.dart'; +export 'web_image_cache.dart'; +export 'web_push_registration.dart'; diff --git a/lib/core/platform/web_app_url.dart b/lib/core/platform/web_app_url.dart new file mode 100644 index 0000000..51b1225 --- /dev/null +++ b/lib/core/platform/web_app_url.dart @@ -0,0 +1,10 @@ +String petfolioAppUrl( + String path, { + Map? queryParameters, +}) { + final normalized = path.startsWith('/') ? path : '/$path'; + final query = queryParameters == null || queryParameters.isEmpty + ? '' + : '?${Uri(queryParameters: queryParameters).query}'; + return '${Uri.base.origin}$normalized$query'; +} diff --git a/lib/core/platform/web_checkout_redirect.dart b/lib/core/platform/web_checkout_redirect.dart new file mode 100644 index 0000000..cf4f200 --- /dev/null +++ b/lib/core/platform/web_checkout_redirect.dart @@ -0,0 +1,2 @@ +export 'web_checkout_redirect_stub.dart' + if (dart.library.html) 'web_checkout_redirect_web.dart'; diff --git a/lib/core/platform/web_checkout_redirect_stub.dart b/lib/core/platform/web_checkout_redirect_stub.dart new file mode 100644 index 0000000..c398cc2 --- /dev/null +++ b/lib/core/platform/web_checkout_redirect_stub.dart @@ -0,0 +1,3 @@ +Future openWebCheckoutUrl(String url) async { + return false; +} diff --git a/lib/core/platform/web_checkout_redirect_web.dart b/lib/core/platform/web_checkout_redirect_web.dart new file mode 100644 index 0000000..ea05f30 --- /dev/null +++ b/lib/core/platform/web_checkout_redirect_web.dart @@ -0,0 +1,17 @@ +import 'dart:js_interop'; + +@JS('window') +external _Window get _window; + +extension type _Window(JSObject _) implements JSObject { + external _Location get location; +} + +extension type _Location(JSObject _) implements JSObject { + external set href(String value); +} + +Future openWebCheckoutUrl(String url) async { + _window.location.href = url; + return true; +} diff --git a/lib/core/platform/web_image_cache.dart b/lib/core/platform/web_image_cache.dart new file mode 100644 index 0000000..eef6cf3 --- /dev/null +++ b/lib/core/platform/web_image_cache.dart @@ -0,0 +1,36 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +const int webNetworkImageMemCacheMax = 1080; +const int webNetworkImageMemCacheFeed = 640; +const int webNetworkImageMemCacheThumb = 256; +const int webNetworkImageMemCacheAvatar = 160; + +int? networkImageMemCacheWidth( + BuildContext context, + double logicalWidth, { + int? maxPixels, +}) { + final dpr = MediaQuery.devicePixelRatioOf(context); + var pixels = (logicalWidth * dpr).round(); + if (pixels <= 0) return null; + if (kIsWeb) { + final cap = maxPixels ?? webNetworkImageMemCacheMax; + pixels = pixels.clamp(1, cap); + } + return pixels; +} + +int? networkImageMaxDiskCacheWidth( + BuildContext context, + double logicalWidth, { + int? maxPixels, +}) { + final mem = networkImageMemCacheWidth( + context, + logicalWidth, + maxPixels: maxPixels, + ); + if (mem == null) return null; + return (mem * 1.25).round(); +} diff --git a/lib/core/platform/web_push_registration.dart b/lib/core/platform/web_push_registration.dart new file mode 100644 index 0000000..79dd738 --- /dev/null +++ b/lib/core/platform/web_push_registration.dart @@ -0,0 +1,2 @@ +export 'web_push_registration_stub.dart' + if (dart.library.html) 'web_push_registration_web.dart'; diff --git a/lib/core/platform/web_push_registration_stub.dart b/lib/core/platform/web_push_registration_stub.dart new file mode 100644 index 0000000..749c633 --- /dev/null +++ b/lib/core/platform/web_push_registration_stub.dart @@ -0,0 +1,9 @@ +Future registerWebPushIfAvailable({ + required String vapidPublicKey, + required String supabaseUrl, + required String supabaseAnonKey, + required String accessToken, +}) async => + false; + +bool get isWebPushSupported => false; diff --git a/lib/core/platform/web_push_registration_web.dart b/lib/core/platform/web_push_registration_web.dart new file mode 100644 index 0000000..adfd94b --- /dev/null +++ b/lib/core/platform/web_push_registration_web.dart @@ -0,0 +1,40 @@ +import 'dart:js_interop'; + +@JS('PetfolioPush.isSupported') +external bool _petfolioPushIsSupported(); + +@JS('PetfolioPush.register') +external JSPromise _petfolioPushRegister( + JSString vapidPublicKey, + JSString supabaseUrl, + JSString supabaseAnonKey, + JSString accessToken, +); + +bool get isWebPushSupported { + try { + return _petfolioPushIsSupported(); + } catch (_) { + return false; + } +} + +Future registerWebPushIfAvailable({ + required String vapidPublicKey, + required String supabaseUrl, + required String supabaseAnonKey, + required String accessToken, +}) async { + if (vapidPublicKey.isEmpty) return false; + try { + await _petfolioPushRegister( + vapidPublicKey.toJS, + supabaseUrl.toJS, + supabaseAnonKey.toJS, + accessToken.toJS, + ).toDart; + return true; + } catch (_) { + return false; + } +} diff --git a/lib/core/router.dart b/lib/core/router.dart index 3accb3a..b02077a 100644 --- a/lib/core/router.dart +++ b/lib/core/router.dart @@ -46,6 +46,7 @@ import '../features/social/presentation/screens/post_detail_screen.dart'; import '../features/social/presentation/screens/social_profile_screen.dart'; import '../features/social/presentation/screens/social_screen.dart'; import '../features/social/presentation/screens/story_viewer_screen.dart'; +import 'navigation/route_overlay_dismissal.dart'; import 'package:petfolio/core/widgets/app_shell.dart'; // ───────────────────────────────────────────────────────────────────────────── @@ -60,6 +61,9 @@ final routerProvider = Provider((ref) { initialLocation: '/home', refreshListenable: notifier, redirect: notifier.redirect, + errorBuilder: (context, state) => _RouterErrorScreen( + location: state.uri.toString(), + ), routes: [ ShellRoute( navigatorKey: _shellNavigatorKey, @@ -337,6 +341,8 @@ final routerProvider = Provider((ref) { // ───────────────────────────────────────────────────────────────────────────── class _RouterNotifier extends ChangeNotifier { + String? _lastDismissedLocation; + _RouterNotifier(this._ref) { // Re-evaluate redirects whenever auth status genuinely changes (sign-in / // sign-out) AND invalidate the pet list so it re-fetches for the new user. @@ -360,6 +366,19 @@ class _RouterNotifier extends ChangeNotifier { FutureOr redirect(BuildContext context, GoRouterState state) { final isLoggedIn = _ref.read(isLoggedInProvider); final loc = state.matchedLocation; + final path = state.uri.path; + + if (_lastDismissedLocation != loc) { + _lastDismissedLocation = loc; + dismissRootOverlayRoutes(_rootNavigatorKey); + } + + if (path == '/' || path.isEmpty) { + return isLoggedIn ? '/home' : '/login'; + } + + if (loc == '/pets') return '/home'; + if (loc == '/shop') return '/marketplace'; // ── Not logged in → only /login and /register are allowed ──────── if (!isLoggedIn) { @@ -431,5 +450,42 @@ class _PetEditMissingScreen extends StatelessWidget { } } +class _RouterErrorScreen extends StatelessWidget { + const _RouterErrorScreen({required this.location}); + + final String location; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Page not found', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text( + location, + style: Theme.of(context).textTheme.bodySmall, + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + FilledButton( + onPressed: () => context.go('/home'), + child: const Text('Go to Home'), + ), + ], + ), + ), + ), + ); + } +} + // DELETED: AppShell, _AppShellHeader, _HeaderIconBtn, _FloatingNav, _NavTab, // _WideNavRail, _NavDestination — moved to lib/core/widgets/app_shell.dart diff --git a/lib/core/services/location_service.dart b/lib/core/services/location_service.dart index f36b739..8c51d8f 100644 --- a/lib/core/services/location_service.dart +++ b/lib/core/services/location_service.dart @@ -15,10 +15,8 @@ enum LocationAccessState { class LocationService { Future readAccessState() async { - if (kIsWeb) return LocationAccessState.unavailable; - try { - if (!await Geolocator.isLocationServiceEnabled()) { + if (!kIsWeb && !await Geolocator.isLocationServiceEnabled()) { return LocationAccessState.servicesDisabled; } return _mapGeolocatorPermission(await Geolocator.checkPermission()); @@ -28,10 +26,8 @@ class LocationService { } Future requestWhenInUseAccess() async { - if (kIsWeb) return LocationAccessState.unavailable; - try { - if (!await Geolocator.isLocationServiceEnabled()) { + if (!kIsWeb && !await Geolocator.isLocationServiceEnabled()) { return LocationAccessState.servicesDisabled; } return _mapGeolocatorPermission(await Geolocator.requestPermission()); @@ -55,17 +51,8 @@ class LocationService { } Future acquireCurrentLatLng() async { - if (kIsWeb) { - throw const ValidationException( - message: - 'Device location is not available in this environment. Matching uses your pet profile location.', - ); - } - var access = await readAccessState(); - // First call — permission may simply not have been requested yet. - // Request it rather than failing immediately. if (access == LocationAccessState.denied) { access = await requestWhenInUseAccess(); } @@ -96,18 +83,15 @@ class LocationService { } try { - // Fast path: use the OS-cached fix if available — no GPS warm-up needed. final last = await Geolocator.getLastKnownPosition(); if (last != null) { return LatLng(latitude: last.latitude, longitude: last.longitude); } - // Slow path: request a fresh fix with a generous timeout so cold-start - // GPS (and emulators with a mock location set) have time to respond. final pos = await Geolocator.getCurrentPosition( - locationSettings: const LocationSettings( + locationSettings: LocationSettings( accuracy: LocationAccuracy.medium, - timeLimit: Duration(seconds: 20), + timeLimit: Duration(seconds: kIsWeb ? 15 : 20), ), ); return LatLng(latitude: pos.latitude, longitude: pos.longitude); diff --git a/lib/core/services/notification_service.dart b/lib/core/services/notification_service.dart index 50ba796..274ca67 100644 --- a/lib/core/services/notification_service.dart +++ b/lib/core/services/notification_service.dart @@ -1,8 +1,12 @@ +import 'dart:convert'; + import 'package:flutter/material.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:timezone/data/latest_all.dart' as tz; import 'package:timezone/timezone.dart' as tz; +typedef NotificationTapCallback = void Function(Map data); + class NotificationService { NotificationService._(); static final instance = NotificationService._(); @@ -11,8 +15,28 @@ class NotificationService { static const _channelId = 'petfolio_care'; static const _channelName = 'Care Reminders'; + static const pushChannelId = 'petfolio_push'; + static const pushChannelName = 'PetFolio'; + static const chatChannelId = 'petfolio_chat_v2'; + static const chatChannelName = 'Chat messages'; + static const chatSoundResource = 'chat_message'; + static const _legacyChatChannelId = 'petfolio_chat'; + + NotificationTapCallback? _onTap; + bool _pluginReady = false; + + Future initialize({NotificationTapCallback? onTap}) async { + _onTap = onTap; + await _ensurePluginReady(); + await _requestAndroidPermissions(); + } + + Future initializeForBackgroundMessaging() async { + await _ensurePluginReady(); + } - Future initialize() async { + Future _ensurePluginReady() async { + if (_pluginReady) return; tz.initializeTimeZones(); _setLocalTimezone(); @@ -25,13 +49,110 @@ class NotificationService { await _plugin.initialize( settings: const InitializationSettings(android: androidSettings, iOS: iosSettings), + onDidReceiveNotificationResponse: _onNotificationResponse, + onDidReceiveBackgroundNotificationResponse: _onBackgroundNotificationResponse, + ); + + final androidPlugin = + _plugin.resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>(); + await androidPlugin?.deleteNotificationChannel( + channelId: _legacyChatChannelId, ); + await androidPlugin?.createNotificationChannel( + const AndroidNotificationChannel( + pushChannelId, + pushChannelName, + description: 'Matches, social, and order alerts', + importance: Importance.high, + playSound: true, + enableVibration: true, + ), + ); + await androidPlugin?.createNotificationChannel( + const AndroidNotificationChannel( + chatChannelId, + chatChannelName, + description: 'New chat messages', + importance: Importance.max, + playSound: true, + enableVibration: true, + sound: RawResourceAndroidNotificationSound(chatSoundResource), + ), + ); + _pluginReady = true; + } + Future _requestAndroidPermissions() async { final androidPlugin = _plugin.resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin>(); - await androidPlugin?.requestNotificationsPermission(); - await androidPlugin?.requestExactAlarmsPermission(); + try { + await androidPlugin?.requestNotificationsPermission(); + await androidPlugin?.requestExactAlarmsPermission(); + } catch (_) {} + } + + @pragma('vm:entry-point') + static void _onBackgroundNotificationResponse(NotificationResponse response) { + NotificationService.instance._handleTapPayload(response.payload); + } + + void _onNotificationResponse(NotificationResponse response) { + _handleTapPayload(response.payload); + } + + void _handleTapPayload(String? payload) { + if (payload == null || payload.isEmpty) return; + try { + final decoded = jsonDecode(payload); + if (decoded is Map) { + _onTap?.call(decoded); + } + } catch (_) {} + } + + Future showPushNotification({ + required int id, + String? title, + String? body, + Map data = const {}, + }) async { + final displayTitle = title?.trim(); + final displayBody = body?.trim(); + if ((displayTitle == null || displayTitle.isEmpty) && + (displayBody == null || displayBody.isEmpty)) { + return; + } + + final isChat = data['type'] == 'chat_message'; + final androidDetails = AndroidNotificationDetails( + isChat ? chatChannelId : pushChannelId, + isChat ? chatChannelName : pushChannelName, + importance: Importance.high, + priority: Priority.high, + playSound: true, + enableVibration: true, + sound: isChat + ? const RawResourceAndroidNotificationSound(chatSoundResource) + : null, + ); + + await _plugin.show( + id: id, + title: displayTitle ?? 'PetFolio', + body: displayBody ?? '', + notificationDetails: NotificationDetails( + android: androidDetails, + iOS: DarwinNotificationDetails( + presentAlert: true, + presentBadge: true, + presentSound: true, + sound: isChat ? 'chat_message.wav' : 'default', + ), + ), + payload: jsonEncode(data), + ); } void _setLocalTimezone() { diff --git a/lib/core/services/stripe_init_service.dart b/lib/core/services/stripe_init_service.dart new file mode 100644 index 0000000..5427aa8 --- /dev/null +++ b/lib/core/services/stripe_init_service.dart @@ -0,0 +1,15 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_stripe/flutter_stripe.dart'; + +bool _stripeSettingsApplied = false; + +Future ensureStripeReady({required String publishableKey}) async { + if (_stripeSettingsApplied) return; + + Stripe.publishableKey = publishableKey; + if (!kIsWeb) { + Stripe.merchantIdentifier = 'merchant.com.petfolio'; + } + await Stripe.instance.applySettings(); + _stripeSettingsApplied = true; +} diff --git a/lib/core/widgets/app_shell.dart b/lib/core/widgets/app_shell.dart index 2c85c02..6b31652 100644 --- a/lib/core/widgets/app_shell.dart +++ b/lib/core/widgets/app_shell.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -90,6 +91,33 @@ class AppShell extends ConsumerWidget { ); } + if (kIsWeb) { + return Scaffold( + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + body: Stack( + children: [ + Positioned.fill(child: child), + Positioned( + top: 0, + left: 0, + right: 0, + child: AppShellHeader(selectedIndex: selectedIndex), + ), + ], + ), + bottomNavigationBar: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: _FloatingNav( + selectedIndex: selectedIndex, + onSelect: (i) => context.go(appShellDestinations[i].path), + ), + ), + ), + ); + } + return Scaffold( backgroundColor: Theme.of(context).scaffoldBackgroundColor, body: Stack( diff --git a/lib/core/widgets/pet_avatar.dart b/lib/core/widgets/pet_avatar.dart index ea35eb8..b8ecc09 100644 --- a/lib/core/widgets/pet_avatar.dart +++ b/lib/core/widgets/pet_avatar.dart @@ -1,5 +1,6 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:petfolio/core/platform/web_image_cache.dart'; import 'package:petfolio/features/pet_profile/data/models/pet_species.dart'; import '../theme/app_colors.dart'; @@ -64,6 +65,11 @@ class PetAvatar extends StatelessWidget { isDark: isDark, species: species, initials: initials, + memCacheWidth: networkImageMemCacheWidth( + context, + d, + maxPixels: webNetworkImageMemCacheAvatar, + ), ); } else { avatar = _SpeciesDisc( @@ -185,6 +191,7 @@ class _NetworkAvatar extends StatelessWidget { required this.isDark, this.species, this.initials, + this.memCacheWidth, }); final String imageUrl; @@ -192,6 +199,7 @@ class _NetworkAvatar extends StatelessWidget { final bool isDark; final PetSpecies? species; final String? initials; + final int? memCacheWidth; @override Widget build(BuildContext context) { @@ -200,6 +208,8 @@ class _NetworkAvatar extends StatelessWidget { imageUrl: imageUrl, width: diameter, height: diameter, + memCacheWidth: memCacheWidth, + memCacheHeight: memCacheWidth, fit: BoxFit.cover, placeholder: (_, _) => SkeletonLoader( width: diameter, diff --git a/lib/features/auth/data/repositories/auth_repository.dart b/lib/features/auth/data/repositories/auth_repository.dart index 8635771..a6ff9d2 100644 --- a/lib/features/auth/data/repositories/auth_repository.dart +++ b/lib/features/auth/data/repositories/auth_repository.dart @@ -1,5 +1,7 @@ import 'package:supabase_flutter/supabase_flutter.dart'; +import '../../../../core/firebase/fcm_service.dart'; + class AuthRepository { const AuthRepository(this._client); @@ -16,7 +18,10 @@ class AuthRepository { Future signUp({required String email, required String password}) => _client.auth.signUp(email: email, password: password); - Future signOut() => _client.auth.signOut(); + Future signOut() async { + await FcmService.instance.clearTokenForSignOut(); + await _client.auth.signOut(); + } Future resetPassword(String email) => _client.auth.resetPasswordForEmail(email.trim()); diff --git a/lib/features/auth/presentation/screens/login_screen.dart b/lib/features/auth/presentation/screens/login_screen.dart index 83b5433..be12388 100644 --- a/lib/features/auth/presentation/screens/login_screen.dart +++ b/lib/features/auth/presentation/screens/login_screen.dart @@ -290,6 +290,7 @@ class _LoginScreenState extends ConsumerState keyboardType: TextInputType.emailAddress, textInputAction: TextInputAction.next, autocorrect: false, + autofillHints: const [AutofillHints.email], validator: (v) { if (v == null || v.trim().isEmpty) { return 'Email is required'; @@ -308,6 +309,7 @@ class _LoginScreenState extends ConsumerState label: 'Password', obscureText: _obscurePassword, textInputAction: TextInputAction.done, + autofillHints: const [AutofillHints.password], onSubmitted: (_) => _submit(), suffixIcon: VisibilityToggle( obscure: _obscurePassword, diff --git a/lib/features/auth/presentation/widgets/auth_widgets.dart b/lib/features/auth/presentation/widgets/auth_widgets.dart index 866208f..faa0e96 100644 --- a/lib/features/auth/presentation/widgets/auth_widgets.dart +++ b/lib/features/auth/presentation/widgets/auth_widgets.dart @@ -99,6 +99,7 @@ class AuthField extends StatefulWidget { this.onSubmitted, this.suffixIcon, this.autocorrect = true, + this.autofillHints, this.validator, }); @@ -110,6 +111,7 @@ class AuthField extends StatefulWidget { final ValueChanged? onSubmitted; final Widget? suffixIcon; final bool autocorrect; + final Iterable? autofillHints; final FormFieldValidator? validator; @override @@ -162,6 +164,7 @@ class _AuthFieldState extends State { textInputAction: widget.textInputAction, onFieldSubmitted: widget.onSubmitted, autocorrect: widget.autocorrect, + autofillHints: widget.autofillHints, enableSuggestions: !widget.obscureText, validator: widget.validator, style: TextStyle( diff --git a/lib/features/care/data/repositories/pet_care_repository.dart b/lib/features/care/data/repositories/pet_care_repository.dart index 5eff4f8..7fffa21 100644 --- a/lib/features/care/data/repositories/pet_care_repository.dart +++ b/lib/features/care/data/repositories/pet_care_repository.dart @@ -3,7 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import '../../../../core/errors/app_exception.dart'; -import '../../../../core/services/notification_service.dart'; +import '../../../../core/platform/platform_notifications.dart'; import '../models/care_streak.dart'; import '../models/care_task.dart'; import '../models/care_task_log.dart'; @@ -402,7 +402,7 @@ class PetCareRepository { return; } await _client.from('care_tasks').delete().eq('id', taskId); - NotificationService.instance.cancelForTask(taskId).ignore(); + PlatformNotifications.instance.cancelForTask(taskId).ignore(); } on AppException { rethrow; } on PostgrestException catch (e) { @@ -582,7 +582,7 @@ class PetCareRepository { final repeating = task.frequency == CareFrequency.daily || task.frequency == CareFrequency.twiceDaily; - NotificationService.instance.scheduleTaskReminder( + PlatformNotifications.instance.scheduleTaskReminder( taskId: task.id, title: task.title, tod: tod, diff --git a/lib/features/care/presentation/screens/care_screen.dart b/lib/features/care/presentation/screens/care_screen.dart index a075a0d..5a9d31c 100644 --- a/lib/features/care/presentation/screens/care_screen.dart +++ b/lib/features/care/presentation/screens/care_screen.dart @@ -19,6 +19,7 @@ import 'package:petfolio/features/care/presentation/utils/care_scheduled_time.da import 'package:petfolio/features/care/presentation/widgets/routine_recommendation_sheet.dart'; import 'package:petfolio/features/care/domain/services/care_recommendation_service.dart'; import 'package:petfolio/features/care/presentation/widgets/gamified_care_ui.dart'; +import 'package:petfolio/features/care/presentation/widgets/web_push_enable_banner.dart'; // ───────────────────────────────────────────────────────────────────────────── // CareScreen @@ -169,6 +170,7 @@ class _CareScreenState extends ConsumerState { activePet: activePet, dashboard: dashboard, ), + const WebPushEnableBanner(), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Column( @@ -1029,7 +1031,14 @@ class _CareTaskCardState extends ConsumerState<_CareTaskCard> TweenSequenceItem(tween: Tween(begin: 1.0, end: 0.0), weight: 30), ]).animate(_xpCtrl); - Widget card = GestureDetector( + final taskLabel = done + ? '${task.title}, completed' + : '${task.title}, $_sublabel, mark complete'; + + Widget card = Semantics( + button: true, + label: taskLabel, + child: GestureDetector( onTap: _toggle, onLongPress: () => _showContextMenu(context), child: AnimatedContainer( @@ -1252,6 +1261,7 @@ class _CareTaskCardState extends ConsumerState<_CareTaskCard> ], ), ), + ), ); // ── XP burst overlay ───────────────────────────────────────────────── @@ -1716,11 +1726,17 @@ class _UtilityHalf extends StatelessWidget { Widget build(BuildContext context) { final pt = Theme.of(context).extension()!; return Expanded( - child: GestureDetector( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.all(13), - child: Column( + child: Semantics( + button: true, + label: '$title. $subtitle', + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(22), + child: Padding( + padding: const EdgeInsets.all(13), + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( @@ -1770,6 +1786,8 @@ class _UtilityHalf extends StatelessWidget { ), ), ], + ), + ), ), ), ), diff --git a/lib/features/care/presentation/screens/medical_vault_screen.dart b/lib/features/care/presentation/screens/medical_vault_screen.dart index 2be15e2..c9941b8 100644 --- a/lib/features/care/presentation/screens/medical_vault_screen.dart +++ b/lib/features/care/presentation/screens/medical_vault_screen.dart @@ -5,6 +5,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:image_picker/image_picker.dart'; + +import '../../../../core/platform/media_picker.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../../../core/theme/app_colors.dart'; @@ -600,10 +602,7 @@ class _AddMedicalRecordSheetState extends ConsumerState { } Future _pickDocument() async { - final file = await ImagePicker().pickImage( - source: ImageSource.gallery, - imageQuality: 90, - ); + final file = await pickGalleryImage(imageQuality: 90); if (file != null && mounted) setState(() => _pickedFile = file); } diff --git a/lib/features/care/presentation/widgets/web_push_enable_banner.dart b/lib/features/care/presentation/widgets/web_push_enable_banner.dart new file mode 100644 index 0000000..0f4f4ee --- /dev/null +++ b/lib/features/care/presentation/widgets/web_push_enable_banner.dart @@ -0,0 +1,92 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import '../../../../core/firebase/fcm_service.dart'; +import '../../../../core/firebase/firebase_env.dart'; +import '../../../../core/theme/theme.dart'; +import '../../../../core/widgets/app_snack_bar.dart'; + +class WebPushEnableBanner extends ConsumerStatefulWidget { + const WebPushEnableBanner({super.key}); + + @override + ConsumerState createState() => _WebPushEnableBannerState(); +} + +class _WebPushEnableBannerState extends ConsumerState { + bool _loading = false; + bool _enabled = false; + + @override + Widget build(BuildContext context) { + if (!kIsWeb || !FirebaseEnv.canRequestWebToken) { + return const SizedBox.shrink(); + } + + if (_enabled) return const SizedBox.shrink(); + + final pt = Theme.of(context).extension()!; + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: Material( + color: pt.surface2, + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.all(14), + child: Row( + children: [ + Icon(Icons.notifications_active_outlined, color: pt.pillarPets), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Enable push notifications for care, matches, and social updates.', + style: TextStyle(fontSize: 13, color: pt.ink700, height: 1.35), + ), + ), + const SizedBox(width: 8), + _loading + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : TextButton( + onPressed: _enable, + child: const Text('Enable'), + ), + ], + ), + ), + ), + ); + } + + Future _enable() async { + final session = Supabase.instance.client.auth.currentSession; + if (session == null) { + AppSnackBar.showError('Sign in to enable notifications.'); + return; + } + + setState(() => _loading = true); + try { + final ok = await FcmService.instance.syncToken(); + if (!mounted) return; + if (ok) { + setState(() => _enabled = true); + AppSnackBar.showSuccess('Push notifications enabled for this browser.'); + } else { + AppSnackBar.showError( + 'Could not enable notifications. Add FIREBASE_VAPID_KEY to .env and rebuild.', + ); + } + } catch (e) { + if (mounted) AppSnackBar.showError(e.toString()); + } finally { + if (mounted) setState(() => _loading = false); + } + } +} diff --git a/lib/features/marketplace/data/repositories/order_repository.dart b/lib/features/marketplace/data/repositories/order_repository.dart index b19d5bb..e7fb65d 100644 --- a/lib/features/marketplace/data/repositories/order_repository.dart +++ b/lib/features/marketplace/data/repositories/order_repository.dart @@ -68,6 +68,37 @@ class OrderRepository { return clientSecret; } + Future createCheckoutSession({ + required String orderId, + required String successUrl, + required String cancelUrl, + }) async { + final response = await _client.functions.invoke( + 'create-payment-intent', + body: { + 'orderId': orderId, + 'payment_method': 'stripe', + 'checkout_mode': true, + 'success_url': successUrl, + 'cancel_url': cancelUrl, + }, + ); + + if (response.status != 200) { + final data = response.data as Map?; + final code = data?['code'] as String?; + if (code == 'SHOP_NOT_VERIFIED') throw const ShopNotVerifiedException(); + throw Exception('Edge Function error ${response.status}: ${response.data}'); + } + + final checkoutUrl = + (response.data as Map)['checkoutUrl'] as String?; + if (checkoutUrl == null) { + throw Exception('Missing checkoutUrl in response'); + } + return checkoutUrl; + } + /// Validate a CoD order via the Edge Function (inventory check, shop active /// guard) and stamp payment_method='cod' on the row server-side. /// Returns the confirmed orderId on success. diff --git a/lib/features/marketplace/presentation/controllers/checkout_controller.dart b/lib/features/marketplace/presentation/controllers/checkout_controller.dart index 878def8..f00d549 100644 --- a/lib/features/marketplace/presentation/controllers/checkout_controller.dart +++ b/lib/features/marketplace/presentation/controllers/checkout_controller.dart @@ -1,10 +1,15 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_stripe/flutter_stripe.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:url_launcher/url_launcher.dart'; +import '../../../../core/platform/platform_payments.dart'; +import '../../../../core/platform/web_app_url.dart'; +import '../../../../core/services/stripe_init_service.dart'; import '../../data/repositories/order_repository.dart' show InsufficientStockException, @@ -21,6 +26,7 @@ import 'cart_controller.dart'; // ───────────────────────────────────────────────────────────────────────────── const _petfolioOfficialShopId = 'cccccccc-0000-0000-0000-cccccccccccc'; +const _stripePublishableKey = String.fromEnvironment('STRIPE_PUBLISHABLE_KEY'); // ───────────────────────────────────────────────────────────────────────────── // Checkout status @@ -28,10 +34,11 @@ const _petfolioOfficialShopId = 'cccccccc-0000-0000-0000-cccccccccccc'; enum CheckoutStatus { idle, - loadingIntent, // inserting order row + calling Edge Function - awaitingSheet, // Stripe Payment Sheet is visible - success, // payment confirmed - failure, // unrecoverable error (not cancel) + loadingIntent, + awaitingSheet, + awaitingRedirect, + success, + failure, } // ───────────────────────────────────────────────────────────────────────────── @@ -63,7 +70,8 @@ class CheckoutState { bool get isLoading => status == CheckoutStatus.loadingIntent || - status == CheckoutStatus.awaitingSheet; + status == CheckoutStatus.awaitingSheet || + status == CheckoutStatus.awaitingRedirect; /// True when [shopId]'s checkout flow is in progress. bool isLoadingShop(String shopId) => isLoading && activeShopId == shopId; @@ -140,10 +148,38 @@ class CheckoutNotifier extends Notifier { ); state = state.copyWith(orderId: orderId); - // 2. Call Edge Function → get Stripe client_secret. + if (useStripeHostedCheckout) { + final checkoutUrl = await _repo.createCheckoutSession( + orderId: orderId, + successUrl: petfolioAppUrl( + '/marketplace/order/$orderId', + queryParameters: const {'stripe': 'success'}, + ), + cancelUrl: petfolioAppUrl( + '/marketplace', + queryParameters: const {'stripe': 'cancel'}, + ), + ); + + state = state.copyWith(status: CheckoutStatus.awaitingRedirect); + + final launched = await launchUrl( + Uri.parse(checkoutUrl), + mode: kIsWeb + ? LaunchMode.platformDefault + : LaunchMode.externalApplication, + webOnlyWindowName: kIsWeb ? '_self' : null, + ); + if (!launched) { + throw Exception('Could not open Stripe Checkout.'); + } + return; + } + final clientSecret = await _repo.createPaymentIntent(orderId); - // 3. Initialize Payment Sheet. + await ensureStripeReady(publishableKey: _stripePublishableKey); + await Stripe.instance.initPaymentSheet( paymentSheetParameters: SetupPaymentSheetParameters( paymentIntentClientSecret: clientSecret, @@ -154,33 +190,9 @@ class CheckoutNotifier extends Notifier { state = state.copyWith(status: CheckoutStatus.awaitingSheet); - // 4. Present Payment Sheet — suspends until dismissed. await Stripe.instance.presentPaymentSheet(); - // 5. Verify backend received the webhook and updated the order row. - // pollOrderConfirmation throws PaymentTimeoutException after 15 s if - // the webhook is delayed — treated as a soft success below. - try { - await _repo.pollOrderConfirmation(orderId); - } on PaymentTimeoutException { - ref.read(cartProvider.notifier).clearShopCart(shopId); - ref.invalidate(buyerOrdersProvider); - state = state.copyWith( - status: CheckoutStatus.success, - verificationPending: true, - clearError: true, - ); - return; - } - - // 6. Backend confirmed — full success. - ref.read(cartProvider.notifier).clearShopCart(shopId); - ref.invalidate(buyerOrdersProvider); - state = state.copyWith( - status: CheckoutStatus.success, - verificationPending: false, - clearError: true, - ); + await _finalizePaidCheckout(shopId: shopId, orderId: orderId); } on StripeException catch (e) { if (e.error.code == FailureCode.Canceled) { if (orderId != null) unawaited(_repo.cancelOrder(orderId)); @@ -275,13 +287,54 @@ class CheckoutNotifier extends Notifier { } } - /// Legacy single-vendor checkout for the PetFolio Official shop. + Future resumeWebCheckoutIfNeeded() async { + if (!kIsWeb) return; + + final orderId = state.orderId; + final shopId = state.activeShopId; + if (state.status != CheckoutStatus.awaitingRedirect || + orderId == null || + shopId == null) { + return; + } + + try { + await _finalizePaidCheckout(shopId: shopId, orderId: orderId); + } catch (_) {} + } + + Future _finalizePaidCheckout({ + required String shopId, + required String orderId, + }) async { + try { + await _repo.pollOrderConfirmation(orderId); + } on PaymentTimeoutException { + ref.read(cartProvider.notifier).clearShopCart(shopId); + ref.invalidate(buyerOrdersProvider); + state = state.copyWith( + status: CheckoutStatus.success, + verificationPending: true, + clearError: true, + ); + return; + } + + ref.read(cartProvider.notifier).clearShopCart(shopId); + ref.invalidate(buyerOrdersProvider); + state = state.copyWith( + status: CheckoutStatus.success, + verificationPending: false, + clearError: true, + ); + } + Future startCheckout() => startCheckoutForShop(_petfolioOfficialShopId); bool get isLoading => state.status == CheckoutStatus.loadingIntent || - state.status == CheckoutStatus.awaitingSheet; + state.status == CheckoutStatus.awaitingSheet || + state.status == CheckoutStatus.awaitingRedirect; - /// Reset back to idle (e.g. after displaying an error snackbar). void reset() => state = const CheckoutState(status: CheckoutStatus.idle); } diff --git a/lib/features/marketplace/presentation/controllers/manual_kyc_controller.dart b/lib/features/marketplace/presentation/controllers/manual_kyc_controller.dart index 28aade6..74e5b3c 100644 --- a/lib/features/marketplace/presentation/controllers/manual_kyc_controller.dart +++ b/lib/features/marketplace/presentation/controllers/manual_kyc_controller.dart @@ -1,7 +1,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:image_picker/image_picker.dart'; +import '../../../../core/platform/media_picker.dart'; import 'my_shop_controller.dart'; @@ -109,19 +109,13 @@ class ManualKycNotifier extends Notifier { void prevStep() => state = state.copyWith(step: state.step - 1, clearDocError: true); Future pickNidImage() async { - final file = await ImagePicker().pickImage( - source: ImageSource.gallery, - imageQuality: 80, - ); + final file = await pickGalleryImage(imageQuality: 80); if (file == null) return; state = state.copyWith(nidBytes: await file.readAsBytes(), clearDocError: true); } Future pickTradeLicenseImage() async { - final file = await ImagePicker().pickImage( - source: ImageSource.gallery, - imageQuality: 80, - ); + final file = await pickGalleryImage(imageQuality: 80); if (file == null) return; state = state.copyWith(tradeLicenseBytes: await file.readAsBytes(), clearDocError: true); } diff --git a/lib/features/marketplace/presentation/screens/cart_screen.dart b/lib/features/marketplace/presentation/screens/cart_screen.dart index 6ea893c..1a9f361 100644 --- a/lib/features/marketplace/presentation/screens/cart_screen.dart +++ b/lib/features/marketplace/presentation/screens/cart_screen.dart @@ -11,6 +11,7 @@ import '../controllers/cart_controller.dart'; import '../controllers/checkout_controller.dart'; import '../controllers/shop_list_controller.dart'; import '../widgets/cart_line_item.dart'; +import '../widgets/web_checkout_resume_listener.dart'; const _petfolioOfficialShopId = 'cccccccc-0000-0000-0000-cccccccccccc'; @@ -42,7 +43,8 @@ class CartScreen extends ConsumerWidget { final groups = cart.itemsByShop.entries.toList(); - return Scaffold( + return WebCheckoutResumeListener( + child: Scaffold( backgroundColor: AppColors.surface1, body: SafeArea( bottom: false, @@ -109,6 +111,7 @@ class CartScreen extends ConsumerWidget { ], ), ), + ), ); } } diff --git a/lib/features/marketplace/presentation/screens/marketplace_screen.dart b/lib/features/marketplace/presentation/screens/marketplace_screen.dart index ae6b8cf..ade8b2f 100644 --- a/lib/features/marketplace/presentation/screens/marketplace_screen.dart +++ b/lib/features/marketplace/presentation/screens/marketplace_screen.dart @@ -1,17 +1,23 @@ import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import '../../../../core/platform/web_image_cache.dart'; import '../../../../core/theme/app_colors.dart'; import '../../../../core/theme/app_theme.dart'; import '../../../../core/widgets/widgets.dart'; import '../../data/models/cart_item.dart'; import '../../data/models/product.dart'; +import '../../data/repositories/order_repository.dart'; import '../controllers/cart_controller.dart'; +import '../controllers/checkout_controller.dart'; import '../controllers/product_list_controller.dart'; +import '../controllers/shop_list_controller.dart'; +import '../widgets/web_checkout_resume_listener.dart'; import 'package:petfolio/features/pet_profile/presentation/controllers/active_pet_controller.dart'; @@ -46,6 +52,26 @@ class _MarketplaceScreenState extends ConsumerState with Tick ProductCategory _selectedCat = ProductCategory.all; final List _flyingItems = []; + @override + void initState() { + super.initState(); + if (kIsWeb) { + WidgetsBinding.instance.addPostFrameCallback((_) => _handleStripeCancelQuery()); + } + } + + void _handleStripeCancelQuery() { + if (!mounted) return; + final params = GoRouterState.of(context).uri.queryParameters; + if (params['stripe'] != 'cancel') return; + final orderId = ref.read(checkoutProvider).orderId; + if (orderId != null) { + ref.read(orderRepositoryProvider).cancelOrder(orderId).ignore(); + } + ref.read(checkoutProvider.notifier).reset(); + context.go('/marketplace'); + AppSnackBar.showError('Checkout cancelled'); + } void _addToCart(Product product, Rect? fromRect) { ref.read(cartProvider.notifier).add(product); @@ -112,20 +138,20 @@ class _MarketplaceScreenState extends ConsumerState with Tick ); } - return Scaffold( - backgroundColor: pt.surface1, - body: Stack( - children: [ - bodyContent, - - // Fly to cart overlay - ..._flyingItems.map((item) { - return _FlyToCartAnim( - key: ValueKey(item.id), - item: item, - ); - }), - ], + return WebCheckoutResumeListener( + child: Scaffold( + backgroundColor: pt.surface1, + body: Stack( + children: [ + bodyContent, + ..._flyingItems.map((item) { + return _FlyToCartAnim( + key: ValueKey(item.id), + item: item, + ); + }), + ], + ), ), ); } @@ -214,7 +240,25 @@ class _FlyToCartAnimState extends State<_FlyToCartAnim> with SingleTickerProvide ), alignment: Alignment.center, child: widget.item.product.imageUrls.isNotEmpty - ? ClipRRect(borderRadius: BorderRadius.circular(16), child: CachedNetworkImage(imageUrl: widget.item.product.imageUrls.first, fit: BoxFit.cover, width: 48, height: 48)) + ? ClipRRect( + borderRadius: BorderRadius.circular(16), + child: CachedNetworkImage( + imageUrl: widget.item.product.imageUrls.first, + fit: BoxFit.cover, + width: 48, + height: 48, + memCacheWidth: networkImageMemCacheWidth( + context, + 48, + maxPixels: webNetworkImageMemCacheThumb, + ), + memCacheHeight: networkImageMemCacheWidth( + context, + 48, + maxPixels: webNetworkImageMemCacheThumb, + ), + ), + ) : const Text('🦴', style: TextStyle(fontSize: 26)), ), ), @@ -580,9 +624,12 @@ class _NewProductTileState extends State<_NewProductTile> { Widget build(BuildContext context) { final pt = Theme.of(context).extension()!; - return GestureDetector( - onTap: widget.onTap, - child: Container( + return Material( + color: Colors.transparent, + child: InkWell( + onTap: widget.onTap, + borderRadius: BorderRadius.circular(24), + child: Container( decoration: BoxDecoration( color: pt.surface1, borderRadius: BorderRadius.circular(24), @@ -613,7 +660,16 @@ class _NewProductTileState extends State<_NewProductTile> { duration: const Duration(milliseconds: 280), curve: Curves.elasticOut, child: widget.product.imageUrls.isNotEmpty - ? CachedNetworkImage(imageUrl: widget.product.imageUrls.first, height: 100, fit: BoxFit.contain) + ? CachedNetworkImage( + imageUrl: widget.product.imageUrls.first, + height: 100, + fit: BoxFit.contain, + memCacheWidth: networkImageMemCacheWidth( + context, + 100, + maxPixels: webNetworkImageMemCacheThumb, + ), + ) : const Text('🦴', style: TextStyle(fontSize: 60)), ), ), @@ -652,14 +708,15 @@ class _NewProductTileState extends State<_NewProductTile> { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text('\$${(widget.product.priceCents / 100).toStringAsFixed(2)}', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w800, color: pt.ink950)), - GestureDetector( - key: _btnKey, - onTap: _handleAdd, - child: AnimatedScale( + IconButton( + key: ValueKey('marketplace_add_${widget.product.id}'), + onPressed: _handleAdd, + icon: AnimatedScale( scale: _popping ? 1.15 : 1.0, duration: const Duration(milliseconds: 280), curve: Curves.elasticOut, child: Container( + key: _btnKey, width: 36, height: 36, decoration: BoxDecoration( @@ -671,6 +728,11 @@ class _NewProductTileState extends State<_NewProductTile> { child: const Icon(Icons.add_rounded, color: Colors.white, size: 20), ), ), + style: IconButton.styleFrom( + padding: EdgeInsets.zero, + minimumSize: const Size(44, 44), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), ), ], ), @@ -681,6 +743,7 @@ class _NewProductTileState extends State<_NewProductTile> { ], ), ), + ), ); } } @@ -689,21 +752,52 @@ class _NewProductTileState extends State<_NewProductTile> { // Cart Drawer // ───────────────────────────────────────────────────────────────────────────── -class CartDrawer extends ConsumerWidget { +const _petfolioOfficialShopId = 'cccccccc-0000-0000-0000-cccccccccccc'; + +class CartDrawer extends ConsumerStatefulWidget { const CartDrawer({super.key}); + @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _CartDrawerState(); +} + +class _CartDrawerState extends ConsumerState { + @override + Widget build(BuildContext context) { final cart = ref.watch(cartProvider); + final checkout = ref.watch(checkoutProvider); + final shopsAsync = ref.watch(shopListProvider); + final verifiedShopIds = shopsAsync.value?.map((s) => s.id).toSet() ?? {}; + + ref.listen(checkoutProvider, (prev, next) { + if (next.status == CheckoutStatus.success && next.orderId != null) { + Navigator.pop(context); + context.pushReplacement('/marketplace/order/${next.orderId}'); + ref.read(checkoutProvider.notifier).reset(); + } + if (next.status == CheckoutStatus.failure && next.errorMessage != null) { + AppSnackBar.showError(next.errorMessage!); + ref.read(checkoutProvider.notifier).reset(); + } + }); + final pt = Theme.of(context).extension()!; final ink950 = pt.ink950; final ink500 = pt.ink500; final surface = pt.surface1; final bg = pt.surface1; - + final items = cart.items.toList(); final subtotal = cart.totalCents / 100; final shipping = items.isNotEmpty ? 4.50 : 0.0; final total = subtotal + shipping; + final shopGroups = cart.itemsByShop.entries.toList(); + final checkoutShopId = shopGroups.length == 1 ? shopGroups.first.key : null; + final canCheckout = checkoutShopId != null && + (checkoutShopId == _petfolioOfficialShopId || + verifiedShopIds.contains(checkoutShopId)); + final isLoading = + checkoutShopId != null && checkout.isLoadingShop(checkoutShopId); return Container( decoration: BoxDecoration( @@ -802,20 +896,44 @@ class CartDrawer extends ConsumerWidget { const SizedBox(height: 16), FilledButton( - onPressed: () {}, + key: const ValueKey('marketplace_cart_checkout'), + onPressed: shopGroups.length > 1 + ? () { + Navigator.pop(context); + context.push('/marketplace/cart'); + } + : !canCheckout || isLoading + ? null + : () => ref + .read(checkoutProvider.notifier) + .startCheckoutForShop(checkoutShopId), style: FilledButton.styleFrom( minimumSize: const Size(double.infinity, 56), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), backgroundColor: Theme.of(context).colorScheme.primary, ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text('Checkout · \$${total.toStringAsFixed(2)}', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700)), - const SizedBox(width: 8), - const Icon(Icons.chevron_right_rounded), - ], - ), + child: isLoading + ? const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + shopGroups.length > 1 + ? 'Checkout by shop' + : 'Checkout · \$${total.toStringAsFixed(2)}', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(width: 8), + const Icon(Icons.chevron_right_rounded), + ], + ), ), const SizedBox(height: 10), Text.rich( @@ -887,7 +1005,21 @@ class _CartItemRow extends ConsumerWidget { ), alignment: Alignment.center, child: item.product.imageUrls.isNotEmpty - ? CachedNetworkImage(imageUrl: item.product.imageUrls.first, fit: BoxFit.cover, width: 40) + ? CachedNetworkImage( + imageUrl: item.product.imageUrls.first, + fit: BoxFit.cover, + width: 40, + memCacheWidth: networkImageMemCacheWidth( + context, + 40, + maxPixels: webNetworkImageMemCacheThumb, + ), + memCacheHeight: networkImageMemCacheWidth( + context, + 40, + maxPixels: webNetworkImageMemCacheThumb, + ), + ) : const Text('🦴', style: TextStyle(fontSize: 30)), ), const SizedBox(width: 12), diff --git a/lib/features/marketplace/presentation/screens/order_confirmation_screen.dart b/lib/features/marketplace/presentation/screens/order_confirmation_screen.dart index be03593..839a5e0 100644 --- a/lib/features/marketplace/presentation/screens/order_confirmation_screen.dart +++ b/lib/features/marketplace/presentation/screens/order_confirmation_screen.dart @@ -1,28 +1,28 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../../../core/theme/app_colors.dart'; import '../../../../core/widgets/primary_pill_button.dart'; +import '../../data/repositories/order_repository.dart'; -// ───────────────────────────────────────────────────────────────────────────── -// OrderConfirmationScreen — shown after successful payment -// ───────────────────────────────────────────────────────────────────────────── - -class OrderConfirmationScreen extends StatefulWidget { +class OrderConfirmationScreen extends ConsumerStatefulWidget { const OrderConfirmationScreen({super.key, required this.orderId}); final String orderId; @override - State createState() => + ConsumerState createState() => _OrderConfirmationScreenState(); } -class _OrderConfirmationScreenState extends State +class _OrderConfirmationScreenState extends ConsumerState with SingleTickerProviderStateMixin { late final AnimationController _controller; late final Animation _scaleAnim; late final Animation _fadeAnim; + bool _confirming = false; @override void initState() { @@ -40,6 +40,23 @@ class _OrderConfirmationScreenState extends State curve: const Interval(0.4, 1.0, curve: Curves.easeOut), ); _controller.forward(); + + if (kIsWeb) { + WidgetsBinding.instance.addPostFrameCallback((_) => _confirmWebPayment()); + } + } + + Future _confirmWebPayment() async { + final stripe = GoRouterState.of(context).uri.queryParameters['stripe']; + if (stripe != 'success' || _confirming) return; + + setState(() => _confirming = true); + try { + await ref.read(orderRepositoryProvider).pollOrderConfirmation(widget.orderId); + } catch (_) { + } finally { + if (mounted) setState(() => _confirming = false); + } } @override @@ -50,8 +67,6 @@ class _OrderConfirmationScreenState extends State @override Widget build(BuildContext context) { - // bottom: false — we add bottomPad manually so SafeArea doesn't - // double-count the home-indicator inset. final bottomPad = MediaQuery.paddingOf(context).bottom; return Scaffold( @@ -61,9 +76,6 @@ class _OrderConfirmationScreenState extends State child: SingleChildScrollView( physics: const ClampingScrollPhysics(), child: ConstrainedBox( - // On tall devices the Column fills the screen (Spacers absorb space). - // On very short devices (≤ 568 dp) the content scrolls instead of - // overflowing. constraints: BoxConstraints( minHeight: MediaQuery.sizeOf(context).height - MediaQuery.paddingOf(context).top, @@ -74,8 +86,6 @@ class _OrderConfirmationScreenState extends State child: Column( children: [ const Spacer(), - - // Success badge ScaleTransition( scale: _scaleAnim, child: Container( @@ -93,7 +103,6 @@ class _OrderConfirmationScreenState extends State ), ), const SizedBox(height: 32), - FadeTransition( opacity: _fadeAnim, child: Column( @@ -108,21 +117,23 @@ class _OrderConfirmationScreenState extends State ), ), const SizedBox(height: 12), - const Text( - 'Your order is confirmed and will\narrive within 3–5 business days.', + Text( + _confirming + ? 'Confirming your payment…' + : 'Your order is confirmed and will\narrive within 3–5 business days.', textAlign: TextAlign.center, - style: TextStyle( + style: const TextStyle( fontSize: 15, height: 1.5, color: AppColors.ink500, ), ), const SizedBox(height: 24), - - // Order reference chip Container( padding: const EdgeInsets.symmetric( - horizontal: 16, vertical: 10), + horizontal: 16, + vertical: 10, + ), decoration: BoxDecoration( borderRadius: BorderRadius.circular(12), color: AppColors.surface2, @@ -130,8 +141,11 @@ class _OrderConfirmationScreenState extends State child: Row( mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.receipt_long_outlined, - size: 16, color: AppColors.ink500), + const Icon( + Icons.receipt_long_outlined, + size: 16, + color: AppColors.ink500, + ), const SizedBox(width: 8), Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -161,9 +175,7 @@ class _OrderConfirmationScreenState extends State ], ), ), - const Spacer(), - FadeTransition( opacity: _fadeAnim, child: Column( @@ -179,7 +191,8 @@ class _OrderConfirmationScreenState extends State label: 'View Order', size: PillButtonSize.lg, isFullWidth: true, - onPressed: () => context.go('/marketplace/orders/${widget.orderId}'), + onPressed: () => + context.go('/marketplace/orders/${widget.orderId}'), ), const SizedBox(height: 12), TextButton( diff --git a/lib/features/marketplace/presentation/screens/vendor/edit_shop_screen.dart b/lib/features/marketplace/presentation/screens/vendor/edit_shop_screen.dart index 5c971f5..c30257c 100644 --- a/lib/features/marketplace/presentation/screens/vendor/edit_shop_screen.dart +++ b/lib/features/marketplace/presentation/screens/vendor/edit_shop_screen.dart @@ -2,7 +2,7 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:image_picker/image_picker.dart'; +import '../../../../../core/platform/media_picker.dart'; import '../../../../../core/theme/app_colors.dart'; import '../../../../../core/theme/app_theme.dart'; @@ -95,10 +95,7 @@ class _EditShopScreenState extends ConsumerState } Future _pickImage({required bool isLogo}) async { - final picked = await ImagePicker().pickImage( - source: ImageSource.gallery, - imageQuality: 85, - ); + final picked = await pickGalleryImage(imageQuality: 85); if (picked == null) return; final bytes = await picked.readAsBytes(); setState(() { diff --git a/lib/features/marketplace/presentation/widgets/web_checkout_resume_listener.dart b/lib/features/marketplace/presentation/widgets/web_checkout_resume_listener.dart new file mode 100644 index 0000000..37913d0 --- /dev/null +++ b/lib/features/marketplace/presentation/widgets/web_checkout_resume_listener.dart @@ -0,0 +1,54 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../data/repositories/order_repository.dart'; +import '../controllers/checkout_controller.dart'; + +class WebCheckoutResumeListener extends ConsumerStatefulWidget { + const WebCheckoutResumeListener({super.key, required this.child}); + + final Widget child; + + @override + ConsumerState createState() => + _WebCheckoutResumeListenerState(); +} + +class _WebCheckoutResumeListenerState extends ConsumerState + with WidgetsBindingObserver { + @override + void initState() { + super.initState(); + if (kIsWeb) WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + if (kIsWeb) WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state != AppLifecycleState.resumed) return; + _handleStripeReturn(); + ref.read(checkoutProvider.notifier).resumeWebCheckoutIfNeeded(); + } + + void _handleStripeReturn() { + final params = GoRouterState.of(context).uri.queryParameters; + if (params['stripe'] == 'cancel') { + final orderId = ref.read(checkoutProvider).orderId; + if (orderId != null) { + ref.read(orderRepositoryProvider).cancelOrder(orderId).ignore(); + } + ref.read(checkoutProvider.notifier).reset(); + if (mounted) context.go('/marketplace'); + } + } + + @override + Widget build(BuildContext context) => widget.child; +} diff --git a/lib/features/matching/data/datasources/matching_supabase_data_source.dart b/lib/features/matching/data/datasources/matching_supabase_data_source.dart index 2c7f4f7..baa0753 100644 --- a/lib/features/matching/data/datasources/matching_supabase_data_source.dart +++ b/lib/features/matching/data/datasources/matching_supabase_data_source.dart @@ -1,3 +1,4 @@ +import 'package:petfolio/core/errors/app_exception.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import '../models/chat_message.dart'; @@ -40,24 +41,29 @@ class MatchingSupabaseDataSource { final uid = currentUserId; if (uid == null) return []; - final raw = await _client.rpc( - 'matching_discovery_candidates', - params: { - 'p_actor_pet_id': actorPetId, - 'p_radius_meters': radiusMeters, - 'p_limit': limit, - 'p_cursor_created_at': cursorCreatedAt?.toUtc().toIso8601String(), - 'p_cursor_pet_id': cursorPetId, - 'p_species': (speciesFilters != null && speciesFilters.isNotEmpty) - ? speciesFilters - : null, - 'p_min_age_years': minAgeYears, - 'p_max_age_years': maxAgeYears, - }, - ); - if (raw == null) return []; - final list = raw as List; - return list + final List raw; + try { + final response = await _client.rpc( + 'matching_discovery_candidates', + params: { + 'p_actor_pet_id': actorPetId, + 'p_radius_meters': radiusMeters, + 'p_limit': limit, + 'p_cursor_created_at': cursorCreatedAt?.toUtc().toIso8601String(), + 'p_cursor_pet_id': cursorPetId, + 'p_species': (speciesFilters != null && speciesFilters.isNotEmpty) + ? speciesFilters + : null, + 'p_min_age_years': minAgeYears, + 'p_max_age_years': maxAgeYears, + }, + ); + if (response == null) return []; + raw = response as List; + } on PostgrestException catch (e) { + throw DatabaseException.fromPostgrest(e); + } + return raw .map( (row) => MatchingDiscoveryRow.fromJson( Map.from(row as Map), diff --git a/lib/features/matching/presentation/controllers/discovery_candidates_controller.dart b/lib/features/matching/presentation/controllers/discovery_candidates_controller.dart index 2f1dcde..c24b88a 100644 --- a/lib/features/matching/presentation/controllers/discovery_candidates_controller.dart +++ b/lib/features/matching/presentation/controllers/discovery_candidates_controller.dart @@ -216,10 +216,12 @@ class DiscoveryCandidatesController extends AsyncNotifier openDirectChat( diff --git a/lib/features/matching/presentation/screens/chat_screen.dart b/lib/features/matching/presentation/screens/chat_screen.dart index 3ad0443..1c8993e 100644 --- a/lib/features/matching/presentation/screens/chat_screen.dart +++ b/lib/features/matching/presentation/screens/chat_screen.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import '../../../../core/theme/app_colors.dart'; @@ -9,6 +8,7 @@ import '../../../../core/widgets/widgets.dart'; import '../../data/models/chat_message.dart'; import '../../../pet_profile/presentation/widgets/pet_switcher_sheet.dart'; import '../controllers/chat_conversation_controller.dart'; +import '../matching_navigation.dart'; // --------------------------------------------------------------------------- // Chat item hierarchy for date-grouped rendering (L-2) @@ -136,7 +136,7 @@ class _ChatScreenState extends ConsumerState { AppHeader( eyebrow: widget.otherPetId != null ? 'Social · Chat' : 'Match · Chat', onOpenSwitcher: () => PetSwitcherSheet.show(context), - onBack: () => context.pop(), + onBack: () => popOrGo(context, '/matching/inbox'), dense: true, actions: const [], ), diff --git a/lib/features/matching/presentation/screens/matches_inbox_screen.dart b/lib/features/matching/presentation/screens/matches_inbox_screen.dart index efe8ba6..0916322 100644 --- a/lib/features/matching/presentation/screens/matches_inbox_screen.dart +++ b/lib/features/matching/presentation/screens/matches_inbox_screen.dart @@ -68,7 +68,7 @@ class _MatchesInboxView extends ConsumerWidget { AppHeader( eyebrow: 'Match · Inbox', onOpenSwitcher: () => PetSwitcherSheet.show(context), - onBack: () => context.pop(), + onBack: () => popOrGo(context, '/matching'), actions: [ AppHeaderAction( iconKey: const ValueKey('matches_inbox_discover'), diff --git a/lib/features/matching/presentation/screens/matching_screen.dart b/lib/features/matching/presentation/screens/matching_screen.dart index fd054a8..249d2d2 100644 --- a/lib/features/matching/presentation/screens/matching_screen.dart +++ b/lib/features/matching/presentation/screens/matching_screen.dart @@ -3,10 +3,13 @@ import 'dart:math' as math; import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:permission_handler/permission_handler.dart'; +import '../../../../core/errors/app_exception.dart'; +import '../../../../core/platform/web_image_cache.dart'; import '../../../../core/services/lat_lng.dart'; import '../../../../core/services/location_providers.dart'; import '../../../../core/services/location_service.dart'; @@ -15,6 +18,7 @@ import '../../../../core/theme/app_theme.dart'; import '../../../../core/widgets/widgets.dart'; import '../../../pet_profile/data/models/pet.dart'; import '../../../pet_profile/presentation/controllers/active_pet_controller.dart'; +import '../../../pet_profile/presentation/controllers/edit_profile_controller.dart'; import '../../../pet_profile/presentation/controllers/pet_list_controller.dart'; import '../../data/models/discovery_candidate.dart'; @@ -40,6 +44,19 @@ bool _isLocationBlocked(LocationAccessState? access) { }; } +bool _isDiscoveryLocationBlocked({ + required LocationAccessState? access, + required bool actorPetHasStoredLocation, +}) { + if (kIsWeb && + actorPetHasStoredLocation && + (access == LocationAccessState.unavailable || + access == LocationAccessState.denied)) { + return false; + } + return _isLocationBlocked(access); +} + LocationAccessState? _accessFromDeviceError(AsyncValue deviceLocation) { if (!deviceLocation.hasError) return null; final message = deviceLocation.error.toString().toLowerCase(); @@ -52,6 +69,17 @@ LocationAccessState? _accessFromDeviceError(AsyncValue deviceLocation) { return LocationAccessState.denied; } +String _discoveryErrorMessage(Object error) { + if (error is DatabaseException) { + final lower = error.message.toLowerCase(); + if (lower.contains('404') || lower.contains('not found')) { + return 'Matching is unavailable. Try again in a moment.'; + } + return error.message; + } + return 'Could not load profiles'; +} + // ───────────────────────────────────────────────────────────────────────────── // Entry point // ───────────────────────────────────────────────────────────────────────────── @@ -205,12 +233,18 @@ class _DiscoveryViewState extends ConsumerState<_DiscoveryView> final overlayActive = _celebrationMatch != null && activePet != null; final locationAccess = locationAccessAsync.asData?.value; - final locationBlocked = _isLocationBlocked(locationAccess); + final actorPetHasStoredLocation = + ref.watch(petMatchLocationProvider(petId)).value == true; + final locationBlocked = _isDiscoveryLocationBlocked( + access: locationAccess, + actorPetHasStoredLocation: actorPetHasStoredLocation, + ); Future enableLocation() async { final access = locationAccess ?? await ref.read(locationServiceProvider).readAccessState(); - if (access == LocationAccessState.permanentlyDenied || - access == LocationAccessState.servicesDisabled) { + if (!kIsWeb && + (access == LocationAccessState.permanentlyDenied || + access == LocationAccessState.servicesDisabled)) { await openAppSettings(); } else { await ref.read(locationServiceProvider).requestWhenInUseAccess(); @@ -240,8 +274,9 @@ class _DiscoveryViewState extends ConsumerState<_DiscoveryView> Icon(Icons.wifi_off_rounded, size: 48, color: pt.ink300), const SizedBox(height: 12), Text( - 'Could not load profiles', + _discoveryErrorMessage(error), style: TextStyle(fontSize: 15, color: pt.ink500), + textAlign: TextAlign.center, ), const SizedBox(height: 16), FilledButton.icon( @@ -859,6 +894,16 @@ class _CardSurface extends StatelessWidget { child: CachedNetworkImage( imageUrl: candidate.avatarUrl!, fit: BoxFit.cover, + memCacheWidth: networkImageMemCacheWidth( + context, + MediaQuery.sizeOf(context).width, + maxPixels: webNetworkImageMemCacheMax, + ), + maxWidthDiskCache: networkImageMaxDiskCacheWidth( + context, + MediaQuery.sizeOf(context).width, + maxPixels: webNetworkImageMemCacheMax, + ), placeholder: (context, url) => Center( child: Container( margin: const EdgeInsets.only(bottom: 60), diff --git a/lib/features/matching/presentation/widgets/match_celebration_overlay.dart b/lib/features/matching/presentation/widgets/match_celebration_overlay.dart index 3513635..639ed6f 100644 --- a/lib/features/matching/presentation/widgets/match_celebration_overlay.dart +++ b/lib/features/matching/presentation/widgets/match_celebration_overlay.dart @@ -2,6 +2,7 @@ import 'dart:math' as math; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import '../../../../core/platform/web_image_cache.dart'; import '../../../../core/theme/app_colors.dart'; import '../../../pet_profile/data/models/pet.dart'; @@ -252,6 +253,16 @@ class _AvatarCircle extends StatelessWidget { width: double.infinity, height: double.infinity, fit: BoxFit.cover, + memCacheWidth: networkImageMemCacheWidth( + context, + 118, + maxPixels: webNetworkImageMemCacheAvatar, + ), + memCacheHeight: networkImageMemCacheWidth( + context, + 118, + maxPixels: webNetworkImageMemCacheAvatar, + ), placeholder: (context, url) => Center( child: Text( fallbackEmoji, diff --git a/lib/features/pet_profile/presentation/screens/edit_profile_screen.dart b/lib/features/pet_profile/presentation/screens/edit_profile_screen.dart index d25fa84..725a26a 100644 --- a/lib/features/pet_profile/presentation/screens/edit_profile_screen.dart +++ b/lib/features/pet_profile/presentation/screens/edit_profile_screen.dart @@ -4,7 +4,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import 'package:image_picker/image_picker.dart'; +import '../../../../core/platform/media_picker.dart'; import 'package:petfolio/core/theme/theme.dart'; import 'package:petfolio/core/widgets/app_snack_bar.dart'; import 'package:petfolio/core/widgets/primary_pill_button.dart'; @@ -30,8 +30,6 @@ class _EditProfileScreenState extends ConsumerState { late final TextEditingController _breedController; late final TextEditingController _bioController; late final TextEditingController _weightController; - final _picker = ImagePicker(); - DateTime? _dateOfBirth; PetGender _gender = PetGender.unknown; String? _activityLevel; @@ -87,7 +85,7 @@ class _EditProfileScreenState extends ConsumerState { } Future _pickImage() async { - final picked = await _picker.pickImage(source: ImageSource.gallery); + final picked = await pickGalleryImage(); if (picked != null) { ref.read(editProfileControllerProvider.notifier).setImage(picked); } diff --git a/lib/features/social/presentation/screens/create_post_screen.dart b/lib/features/social/presentation/screens/create_post_screen.dart index e7772b3..e09ac41 100644 --- a/lib/features/social/presentation/screens/create_post_screen.dart +++ b/lib/features/social/presentation/screens/create_post_screen.dart @@ -1,9 +1,11 @@ -import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:flutter/foundation.dart'; import 'package:image_picker/image_picker.dart'; +import '../../../../core/platform/media_picker.dart'; + import '../../../../core/theme/app_colors.dart'; import '../../../../core/theme/app_theme.dart'; import '../../../../core/widgets/pet_avatar.dart'; @@ -25,6 +27,7 @@ class CreatePostScreen extends ConsumerStatefulWidget { class _CreatePostScreenState extends ConsumerState { final _captionController = TextEditingController(); final _picker = ImagePicker(); + Uint8List? _previewBytes; static const _maxChars = 500; @override @@ -52,13 +55,17 @@ class _CreatePostScreenState extends ConsumerState { ); if (source == null || !mounted) return; - final pickedFile = await _picker.pickImage( - source: source, - maxWidth: 1920, - imageQuality: 85, - ); + final XFile? pickedFile = kIsWeb && source == ImageSource.gallery + ? await pickGalleryImage(maxWidth: 1920, imageQuality: 85) + : await _picker.pickImage( + source: source, + maxWidth: 1920, + imageQuality: 85, + ); if (pickedFile != null && mounted) { ref.read(createPostControllerProvider.notifier).setImage(pickedFile); + final bytes = await pickedFile.readAsBytes(); + if (mounted) setState(() => _previewBytes = bytes); } } @@ -140,10 +147,15 @@ class _CreatePostScreenState extends ConsumerState { padding: const EdgeInsets.symmetric(horizontal: 16), child: _ImageWell( image: state.image, + previewBytes: _previewBytes, isSubmitting: state.isSubmitting, onTap: _showImageSourceSheet, - onRemove: () => - ref.read(createPostControllerProvider.notifier).removeImage(), + onRemove: () { + setState(() => _previewBytes = null); + ref + .read(createPostControllerProvider.notifier) + .removeImage(); + }, ), ), @@ -331,12 +343,14 @@ class _PetIdentityRow extends StatelessWidget { class _ImageWell extends StatelessWidget { const _ImageWell({ required this.image, + required this.previewBytes, required this.isSubmitting, required this.onTap, required this.onRemove, }); final XFile? image; + final Uint8List? previewBytes; final bool isSubmitting; final VoidCallback onTap; final VoidCallback onRemove; @@ -363,9 +377,9 @@ class _ImageWell extends StatelessWidget { color: image != null ? Colors.transparent : pt.line, width: 1.5, ), - image: image != null + image: image != null && previewBytes != null ? DecorationImage( - image: FileImage(File(image!.path)), + image: MemoryImage(previewBytes!), fit: BoxFit.cover, ) : null, diff --git a/lib/features/social/presentation/screens/create_story_screen.dart b/lib/features/social/presentation/screens/create_story_screen.dart index 3299daa..b0edd12 100644 --- a/lib/features/social/presentation/screens/create_story_screen.dart +++ b/lib/features/social/presentation/screens/create_story_screen.dart @@ -1,10 +1,13 @@ import 'dart:async'; -import 'dart:typed_data'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:flutter/foundation.dart'; import 'package:image_picker/image_picker.dart'; + +import '../../../../core/platform/media_picker.dart'; +import '../../../../core/platform/web_image_cache.dart'; import 'package:http/http.dart' as http; import '../../../../core/theme/app_colors.dart'; @@ -70,11 +73,13 @@ class _CreateStoryScreenState extends ConsumerState { } Future _pickFromGallery() async { - final pickedFile = await _picker.pickImage( - source: ImageSource.gallery, - maxWidth: 1920, - imageQuality: 85, - ); + final pickedFile = kIsWeb + ? await pickGalleryImage(maxWidth: 1920, imageQuality: 85) + : await _picker.pickImage( + source: ImageSource.gallery, + maxWidth: 1920, + imageQuality: 85, + ); if (pickedFile != null && mounted) { ref.read(createPostControllerProvider.notifier).setImage(pickedFile); await _loadPreviewBytes(pickedFile); @@ -542,7 +547,11 @@ class _MockImageTile extends StatelessWidget { child: CachedNetworkImage( imageUrl: url, fit: BoxFit.cover, - memCacheWidth: 200, + memCacheWidth: networkImageMemCacheWidth( + context, + 100, + maxPixels: webNetworkImageMemCacheThumb, + ), placeholder: (context, _) => Container( color: Theme.of(context).colorScheme.surfaceContainerHighest, ), diff --git a/lib/features/social/presentation/screens/post_detail_screen.dart b/lib/features/social/presentation/screens/post_detail_screen.dart index 4d0b38c..1233081 100644 --- a/lib/features/social/presentation/screens/post_detail_screen.dart +++ b/lib/features/social/presentation/screens/post_detail_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../../../core/errors/app_exception.dart'; +import '../../../../core/platform/web_image_cache.dart'; import '../../../../core/theme/app_colors.dart'; import '../../../../core/theme/app_theme.dart'; import '../../../../core/widgets/app_snack_bar.dart'; @@ -423,8 +424,16 @@ class _PostImagesState extends State<_PostImages> { itemBuilder: (ctx, i) => CachedNetworkImage( imageUrl: post.imageUrls[i], fit: BoxFit.cover, - memCacheWidth: 800, // Slightly higher for detail view - maxWidthDiskCache: 1200, + memCacheWidth: networkImageMemCacheWidth( + ctx, + MediaQuery.sizeOf(ctx).width, + maxPixels: webNetworkImageMemCacheMax, + ), + maxWidthDiskCache: networkImageMaxDiskCacheWidth( + ctx, + MediaQuery.sizeOf(ctx).width, + maxPixels: webNetworkImageMemCacheMax, + ), placeholder: (ctx, _) => Container( color: Theme.of(context).colorScheme.surface, ), diff --git a/lib/features/social/presentation/screens/social_profile_screen.dart b/lib/features/social/presentation/screens/social_profile_screen.dart index 9fed3f0..951e22f 100644 --- a/lib/features/social/presentation/screens/social_profile_screen.dart +++ b/lib/features/social/presentation/screens/social_profile_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:share_plus/share_plus.dart'; +import '../../../../core/platform/web_image_cache.dart'; import '../../../../core/theme/theme.dart'; import '../../../care/data/models/pet_awards_summary.dart'; import '../../../care/presentation/controllers/pet_awards_provider.dart'; @@ -193,8 +194,16 @@ class SocialProfileScreen extends ConsumerWidget { CachedNetworkImage( imageUrl: post.imageUrls.first, fit: BoxFit.cover, - memCacheWidth: 400, - maxWidthDiskCache: 800, + memCacheWidth: networkImageMemCacheWidth( + context, + 120, + maxPixels: webNetworkImageMemCacheThumb, + ), + maxWidthDiskCache: networkImageMaxDiskCacheWidth( + context, + 120, + maxPixels: webNetworkImageMemCacheThumb, + ), placeholder: (ctx, url) => Container(color: pt.surface2), errorWidget: (ctx, url, err) => Container( color: pt.surface2, diff --git a/lib/features/social/presentation/screens/social_screen.dart b/lib/features/social/presentation/screens/social_screen.dart index 16f152d..27e1449 100644 --- a/lib/features/social/presentation/screens/social_screen.dart +++ b/lib/features/social/presentation/screens/social_screen.dart @@ -8,6 +8,7 @@ import 'package:go_router/go_router.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; +import '../../../../core/platform/web_image_cache.dart'; import '../../../../core/theme/app_colors.dart'; import '../../../../core/theme/app_theme.dart'; import '../../../../core/widgets/dashed_circle_painter.dart'; @@ -988,7 +989,20 @@ class _PostCardState extends State with SingleTickerProviderStateMixin ), clipBehavior: Clip.hardEdge, child: widget.post.imageUrls.isNotEmpty - ? CachedNetworkImage(imageUrl: widget.post.imageUrls.first, fit: BoxFit.cover) + ? CachedNetworkImage( + imageUrl: widget.post.imageUrls.first, + fit: BoxFit.cover, + memCacheWidth: networkImageMemCacheWidth( + context, + MediaQuery.sizeOf(context).width - 28, + maxPixels: webNetworkImageMemCacheFeed, + ), + maxWidthDiskCache: networkImageMaxDiskCacheWidth( + context, + MediaQuery.sizeOf(context).width - 28, + maxPixels: webNetworkImageMemCacheFeed, + ), + ) : Center( child: Container( width: 150, diff --git a/lib/features/social/presentation/screens/story_viewer_screen.dart b/lib/features/social/presentation/screens/story_viewer_screen.dart index ea0dfda..b04b622 100644 --- a/lib/features/social/presentation/screens/story_viewer_screen.dart +++ b/lib/features/social/presentation/screens/story_viewer_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import '../../../../core/platform/web_image_cache.dart'; import '../../data/models/story.dart'; import '../controllers/story_controller.dart'; @@ -274,7 +275,16 @@ class _StoryViewerScreenState extends ConsumerState { child: CachedNetworkImage( imageUrl: story.imageUrl, fit: BoxFit.cover, - memCacheWidth: 1080, + memCacheWidth: networkImageMemCacheWidth( + context, + MediaQuery.sizeOf(context).width, + maxPixels: webNetworkImageMemCacheMax, + ), + maxWidthDiskCache: networkImageMaxDiskCacheWidth( + context, + MediaQuery.sizeOf(context).width, + maxPixels: webNetworkImageMemCacheMax, + ), placeholder: (context, url) => const Center( child: CircularProgressIndicator.adaptive( valueColor: AlwaysStoppedAnimation(Colors.white), diff --git a/lib/features/social/presentation/widgets/post_comments_bottom_sheet.dart b/lib/features/social/presentation/widgets/post_comments_bottom_sheet.dart index fd385e8..80b98a4 100644 --- a/lib/features/social/presentation/widgets/post_comments_bottom_sheet.dart +++ b/lib/features/social/presentation/widgets/post_comments_bottom_sheet.dart @@ -601,7 +601,13 @@ class _CommentInputBarState extends State<_CommentInputBar> { : null, ), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: TextField( + child: Semantics( + textField: true, + label: widget.replyingToHandle != null + ? 'Reply to ${widget.replyingToHandle}' + : 'Add a comment', + child: TextField( + key: const ValueKey('social_comment_input'), controller: widget.controller, focusNode: _focusNode, minLines: 1, @@ -625,6 +631,7 @@ class _CommentInputBarState extends State<_CommentInputBar> { contentPadding: const EdgeInsets.symmetric(vertical: 4), ), ), + ), ), ), const SizedBox(width: 12), diff --git a/lib/firebase_options.dart b/lib/firebase_options.dart new file mode 100644 index 0000000..d586f03 --- /dev/null +++ b/lib/firebase_options.dart @@ -0,0 +1,50 @@ +import 'package:firebase_core/firebase_core.dart' show FirebaseOptions; +import 'package:flutter/foundation.dart' + show defaultTargetPlatform, kIsWeb, TargetPlatform; + +class DefaultFirebaseOptions { + static FirebaseOptions get currentPlatform { + if (kIsWeb) { + return web; + } + switch (defaultTargetPlatform) { + case TargetPlatform.android: + return android; + case TargetPlatform.iOS: + return ios; + case TargetPlatform.macOS: + return ios; + default: + throw UnsupportedError( + 'DefaultFirebaseOptions are not configured for $defaultTargetPlatform', + ); + } + } + + static const FirebaseOptions web = FirebaseOptions( + apiKey: 'AIzaSyDG5_rufMTdwV9X2wc7M5YNyEkWwXN8tGM', + appId: '1:86798095066:web:61021d3c9119434a68cbe3', + messagingSenderId: '86798095066', + projectId: 'petfolio-v1', + authDomain: 'petfolio-v1.firebaseapp.com', + storageBucket: 'petfolio-v1.firebasestorage.app', + measurementId: 'G-57M4WLN8YH', + ); + + static const FirebaseOptions android = FirebaseOptions( + apiKey: 'AIzaSyDW3jiVahlDqTey6aj0zsj9Z-Dj-kvamgE', + appId: '1:86798095066:android:5b5d6008f7ab957f68cbe3', + messagingSenderId: '86798095066', + projectId: 'petfolio-v1', + storageBucket: 'petfolio-v1.firebasestorage.app', + ); + + static const FirebaseOptions ios = FirebaseOptions( + apiKey: 'AIzaSyC5xx8ZU50o8VHMVsFCQIGOsbnVt11Q4sM', + appId: '1:86798095066:ios:0fdb00b077c71cc668cbe3', + messagingSenderId: '86798095066', + projectId: 'petfolio-v1', + storageBucket: 'petfolio-v1.firebasestorage.app', + iosBundleId: 'com.example.petfolio', + ); +} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 60499d4..623dfd9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,9 +1,11 @@ import 'dart:ui'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; +import 'package:flutter_web_plugins/url_strategy.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_stripe/flutter_stripe.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:marionette_flutter/marionette_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; @@ -11,8 +13,14 @@ import 'package:supabase_flutter/supabase_flutter.dart'; import 'marionette_debug_gate_stub.dart' if (dart.library.io) 'marionette_debug_gate_io.dart' as marionette_gate; +import 'core/firebase/fcm_background_handler.dart'; +import 'core/firebase/fcm_service.dart'; +import 'firebase_options.dart'; import 'core/router.dart'; +import 'features/auth/presentation/controllers/auth_controller.dart'; +import 'core/platform/platform_notifications.dart'; import 'core/services/notification_service.dart'; +import 'core/services/stripe_init_service.dart'; import 'core/theme/theme.dart'; import 'core/widgets/app_snack_bar.dart'; @@ -47,6 +55,10 @@ Future main() async { WidgetsFlutterBinding.ensureInitialized(); } + if (kIsWeb) { + usePathUrlStrategy(); + } + FlutterError.onError = (details) { FlutterError.presentError(details); }; @@ -57,15 +69,24 @@ Future main() async { _assertEnvVars(); - GoogleFonts.config.allowRuntimeFetching = false; + GoogleFonts.config.allowRuntimeFetching = kIsWeb; - Stripe.publishableKey = _stripePublishableKey; - Stripe.merchantIdentifier = 'merchant.com.petfolio'; - await Stripe.instance.applySettings(); + if (!kIsWeb) { + await ensureStripeReady(publishableKey: _stripePublishableKey); + } await Supabase.initialize(url: _supabaseUrl, anonKey: _supabaseAnonKey); - if (!kIsWeb) await NotificationService.instance.initialize(); + if (!kIsWeb) { + await NotificationService.instance.initialize( + onTap: FcmService.instance.handleNotificationTap, + ); + await PlatformNotifications.instance.initialize(); + } + + await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); + FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler); + await FcmService.instance.initialize(); runApp(const ProviderScope(child: PetfolioApp())); } @@ -78,16 +99,24 @@ class PetfolioApp extends ConsumerWidget { final router = ref.watch(routerProvider); final themeMode = ref.watch(themeProvider); + FcmService.instance.updateRouter(router); + ref.listen(authStateProvider, (previous, next) { + next.whenData((state) async { + if (state.session != null) { + await FcmService.instance.syncToken(); + } else { + await FcmService.instance.clearTokenForSignOut(); + } + }); + }); + return MaterialApp.router( title: 'PetFolio', debugShowCheckedModeBanner: false, scaffoldMessengerKey: appSnackBarMessengerKey, - - // ── Design system themes ───────────────────────────────────────────── theme: AppTheme.light(), darkTheme: AppTheme.dark(), themeMode: themeMode, - routerConfig: router, ); } diff --git a/progress.md b/progress.md index 80ee014..d55de17 100644 --- a/progress.md +++ b/progress.md @@ -1540,3 +1540,164 @@ Phase complete — please run (/remember) to save tokens before proceeding to th Phase complete — please run (/remember) to save tokens before proceeding to the next phase. +## 2026-06-05 — PWA Phase 1 (web/PWA P0 from PWA_WEB_AUDIT.md) + +- **`web/index.html`** — Removed `body` safe-area CSS (Flutter owns insets); splash 25s timeout + error copy; `touch-action: manipulation`; Stripe.js moved to `defer` at end of body; window error logging. +- **`web/pwa_banner.js`** — Safe-area bottom padding; honest install copy (no offline claim); `role="complementary"`. +- **`lib/core/services/stripe_init_service.dart`** — `ensureStripeReady()`; web defers `applySettings` until checkout; Android unchanged at cold start. +- **`lib/main.dart`** — Stripe init only on non-web at startup. +- **`lib/core/services/location_service.dart`** — Enabled `geolocator` on web (removed hard `unavailable`). +- **`lib/features/care/data/repositories/pet_care_repository.dart`** — Skip local notification scheduling on web. +- **`lib/features/matching/presentation/screens/matching_screen.dart`** — Web discovery unblocked when pet has profile location; no `openAppSettings` on web. +- **`lib/core/widgets/app_shell.dart`** — Web uses `bottomNavigationBar` instead of floating stack nav. + +**Next step (Phase 2):** Web Push care reminders, Stripe Checkout redirect on web, web file picker for uploads, `PlatformService` facade. + +Phase complete — please run (/remember) to save tokens before proceeding to the next phase. + +## 2026-06-05 — PWA Phase 2 (parity from PWA_WEB_AUDIT.md) + +- **Platform layer** (`lib/core/platform/`) — notifications io/web, `pickGalleryImage`, payments flag, web push JS bridge. +- **DB** (`20260605100000_pwa_phase2.sql`) — `stripe_checkout_session_id`, `care_web_reminders`, `user_web_push_subscriptions` + RLS. +- **Edge functions** — `create-payment-intent` checkout_mode + URL; `register-web-push-subscription`; `stripe-webhook` `checkout.session.expired`. +- **Web checkout** — hosted Stripe Checkout on `kIsWeb`; cart resume listener; order confirmation polls on `?stripe=success`. +- **Care web** — reminders stored in Supabase; optional push enable banner when `WEB_PUSH_VAPID_PUBLIC_KEY` is set. +- **Uploads** — gallery pick abstraction on profile, social, marketplace KYC, medical vault. + +**Deploy:** `npx supabase db push`; deploy `create-payment-intent`, `register-web-push-subscription`, `stripe-webhook`; add Stripe webhook event `checkout.session.expired`; set `WEB_PUSH_VAPID_PUBLIC_KEY` for push UI. + +**Next step (Phase 3):** Manifest screenshots/shortcuts, iOS startup images, image mem-cache caps on web. + +Phase complete — please run (/remember) to save tokens before proceeding to the next phase. + +## 2026-06-05 — PWA Phase 3 (polish from PWA_WEB_AUDIT.md) + +- **`web/manifest.json`** — `id`, `screenshots` (narrow/wide), app shortcuts for `/care`, `/matching`, `/marketplace`. +- **`web/screenshots/`**, **`web/splash/`** — branded PNG assets; **`web/index.html`** — `apple-touch-startup-image` for common iPhone portrait sizes + fallback. +- **`lib/core/platform/web_image_cache.dart`** — Safari-friendly `memCacheWidth` caps on web; applied to `PetAvatar`, social feed/stories/profile, matching deck, marketplace product thumbs. + +**Next step:** Device-verify iOS PWA install + shortcuts; replace screenshot PNGs with real marketing captures when available. + +Phase complete — please run (/remember) to save tokens before proceeding to the next phase. + +## 2026-06-05 — Match discovery RPC signature fix (web + Android) + +- **Root cause:** `20260604000000_fix_matching_discovery_candidates_offset_signature.sql` replaced the RPC with `p_offset` while `MatchingSupabaseDataSource` calls cursor params (`p_cursor_created_at`, `p_cursor_pet_id`) → PostgREST **404** on deck load. +- **Fix:** `supabase/migrations/20260605120000_restore_matching_discovery_candidates_cursor.sql` — drop offset overload, restore keyset cursor RPC + grants; applied to hosted `jqyjvhwlcqcsuwcqgcwf` via `npx supabase db query --linked`. +- **Cleanup:** `20260604000000_*` reduced to no-op for fresh migration runs. + +**Verify:** Hot-restart app → Match tab should load cards (or empty deck), not “Could not load profiles”. Active pet must have `location` + `is_discoverable = true`. + +Phase complete — please run (/remember) to save tokens before proceeding to the next phase. + +## 2026-06-05 — Web automation review fixes + +- **Router** — `#/` redirects to `/home` or `/login`; `errorBuilder` shows “Page not found” + **Go to Home** (replaces bare GoRouter 404). +- **Web shell** — PWA install banner injects after `flutter-first-frame` (lower z-index); auto-enables Flutter semantics placeholder on first frame. +- **Manifest** — `start_url` → `/home`. +- **Care** — Nutrition/Medical utility halves use `InkWell` + semantics for reliable web taps. +- **Marketplace** — Product **+** uses `IconButton` + `ValueKey` (44px target); card uses `InkWell`. +- **Matching** — RPC errors surface as `DatabaseException`; empty-deck replenish failures become `AsyncError`; clearer deck error copy. +- **Login** — `AutofillHints.email` / `password` on auth fields. + +Phase complete — please run (/remember) to save tokens before proceeding to the next phase. + +## 2026-06-05 — Web automation P1 fixes (modals, checkout, routes) + +- **Modal leak** — `dismissRootOverlayRoutes` on shell route change via `_RouterNotifier.redirect` (`lib/core/navigation/route_overlay_dismissal.dart`). +- **Deep links** — `#/pets` → `/home`, `#/shop` → `/marketplace`. +- **Web Stripe checkout** — `petfolioAppUrl()` hash URLs for success/cancel; `launchUrl` with `webOnlyWindowName: '_self'`; `CartDrawer` checkout wired to `checkoutProvider` (was no-op); multi-shop → `/marketplace/cart`. +- **Stripe return** — `WebCheckoutResumeListener` + marketplace `stripe=cancel` query handling. +- **Care a11y** — `Semantics` labels on care task cards. +- **Social** — comment field `Semantics` + `ValueKey`. +- **Deploy** — `npx supabase functions deploy create-payment-intent` (hosted). + +**Verify:** Hot-restart web → open comments, switch tab (sheet closes); add to cart → Checkout opens Stripe same tab; return URL `/#/marketplace/order/:id?stripe=success`. + +Phase complete — please run (/remember) to save tokens before proceeding to the next phase. + +## 2026-06-05 — Supabase + Stripe MCP infra pass + +- **Migration `pwa_phase2` applied (hosted)** — `stripe_checkout_session_id`, `user_web_push_subscriptions`, `care_web_reminders` + RLS. +- **`register-web-push-subscription`** deployed (MCP v1, JWT on). +- **`stripe-webhook` v11** — `checkout.session.completed` + shared `fulfillPaidOrder`; redeployed `--no-verify-jwt`. +- **Stripe webhooks** — platform `we_1TYC0v…`: PI succeeded/failed, checkout completed/expired; Connect `we_1TYEIx…`: `account.updated`. +- **Waitlist RLS** — `20260605130000_waitlist_insert_policy_hardening.sql` applied (email-format check). +- **Checkout trace** — stale `pending` orders match abandoned PIs (`requires_payment_method`), not webhook gaps. + +**Manual:** `WEB_PUSH_VAPID_PUBLIC_KEY` in `.env`; enable Auth leaked-password protection in Supabase dashboard. + +Phase complete — please run (/remember) to save tokens before proceeding to the next phase. + +## 2026-06-06 — Firebase Cloud Messaging (Android + Web) + +- **Client** — `firebase_core` + `firebase_messaging`; `lib/core/firebase/` (`FirebaseEnv` dart-defines, `FcmService`, `FcmMessageRouter`, token upsert); init in `main.dart`; auth listener registers/clears tokens. +- **Schema** — `user_fcm_devices` migration `20260606120000_user_fcm_devices.sql` (RLS own-row). +- **Web** — `firebase-messaging-sw.js`, `tool/sync_firebase_web_config.dart` → `web/firebase-config.js` from `.env`; Care banner prefers FCM when configured (legacy VAPID fallback). +- **Server** — `supabase/functions/send-fcm-notification` (FCM HTTP v1; needs `FIREBASE_SERVICE_ACCOUNT_JSON` secret). +- **Payload routes** — `care_reminder`, `match`, `chat_message`, `like`/`comment`/`follow`, `order`, `seller_order` (+ optional `route` override). +- **`.env.example`** — Firebase keys + `FIREBASE_VAPID_KEY` for web. + +**Manual:** Firebase Console → Android + Web apps → fill `.env` → `dart run tool/sync_firebase_web_config.dart` → `npx supabase db push` → deploy edge fn with service account JSON. + +## 2026-06-06 — FCM server dispatch (all modules) + +- **Migration `20260607140000_fcm_dispatch_system`** (hosted via `db query --linked`): `pg_net`, `private.fcm_internal_config`, `fcm_push_outbox`, DB triggers on `notifications`, `chat_messages`, `matches`, `marketplace_orders`; pg_cron for outbox + care reminders. +- **Edge functions deployed**: `send-fcm-notification`, `process-fcm-outbox`, `process-care-fcm-reminders` (`--no-verify-jwt`, `x-fcm-dispatch-secret`). +- **Care**: Android/web upsert `care_web_reminders` + FCM cron; local notifications kept on Android. +- **Client**: `care_fcm_reminder_sync.dart`; router paths for KYC push types. +- **Manual:** Set `FCM_DISPATCH_SECRET` + `FIREBASE_SERVICE_ACCOUNT_JSON` in Supabase secrets; `UPDATE private.fcm_internal_config SET dispatch_secret = ...`. + +Phase complete — please run (/remember) to save tokens before proceeding to the next phase. + +## 2026-06-06 — petfolio-v1 FCM wiring (console config) + +- **`lib/firebase_options.dart`** — Android + Web app IDs/keys from `google-services.json` and Firebase console (project `petfolio-v1`). +- **FCM-only** — Removed legacy `push_register.js` / VAPID web-push banner path; web uses FCM + `firebase-messaging-sw.js` + committed `web/firebase-config.js`. +- **Android** — `com.google.gms.google-services` + `android/app/google-services.json`. +- **`.env`** — `FIREBASE_VAPID_KEY` required for web tokens; service account JSON gitignored → Supabase secret `FIREBASE_SERVICE_ACCOUNT_JSON`. +- **`firebase-instructions.md`** — Sanitized (no private keys in repo). + +Phase complete — please run (/remember) to save tokens before proceeding to the next phase. + +## 2026-06-05 — FCM push fix (server JWT + client heads-up) + +- **Root cause (hosted)** — `send-fcm-notification` HTTP 500: `FIREBASE_SERVICE_ACCOUNT_JSON` Supabase secret is invalid JSON; edge logs failed on chat INSERT (~20:54 UTC) despite 2 Android tokens in `user_fcm_devices`. +- **Server** — `fcm_send.ts` base64url JWT fix, Android `petfolio_push` channel, stale token cleanup; functions redeployed. +- **Client** — FCM foreground/background shows tray notifications via `fcm_push_display.dart` + `NotificationService.showPushNotification`; tap → `FcmMessageRouter`; Android default channel meta-data. +- **Tool** — `tool/set_fcm_supabase_secrets.ps1`. + +**Manual:** Re-run secret script with Firebase adminsdk JSON, hot-restart both devices, test chat with recipient app backgrounded. + +Phase complete — please run (/remember) to save tokens before proceeding to the next phase. + +## 2026-06-05 — FCM secrets + VAPID wired (hosted verified) + +- **`petfolio-v1-firebase-adminsdk-fbsvc-849086572f.json`** in repo root (gitignored); `.\tool\set_fcm_supabase_secrets.ps1` uploads minified `FIREBASE_SERVICE_ACCOUNT_JSON` + `FCM_DISPATCH_SECRET` to Supabase. +- **Hosted test:** `send-fcm-notification` → `{"sent":1,"total":1}` (server push pipeline live). +- **`.env`:** `FIREBASE_VAPID_KEY` matches Web Push certificate public key; `dart run tool/sync_firebase_web_config.dart` refreshed `web/firebase-config.js`. +- **Script fixes:** PowerShell `Join-Path` + optional default adminsdk path; `supabase/.secrets.fcm.env` gitignored. + +**Manual:** Full restart on both Android devices + web (`flutter run --dart-define-from-file=.env`); test chat with recipient backgrounded; web Care tab → Enable push banner if token not auto-registered. + +Phase complete — please run (/remember) to save tokens before proceeding to the next phase. + +## 2026-06-05 — Vercel web CI secrets + deploy workflow + +- **GitHub Actions secrets:** `gh secret set -f .env -R CodeStorm-Hub/petfolio` synced all `.env` keys; `VERCEL_TOKEN` unchanged (set manually). +- **`deploy-web.yml`:** Flutter 3.44.0 pin, `FIREBASE_VAPID_KEY` dart-define, CI `.env` materialization + `dart run tool/sync_firebase_web_config.dart`, Node 22 + global Vercel CLI, concurrency + `workflow_dispatch`. +- **`vercel.json`:** `installCommand: ""` so prebuilt Flutter output is not npm-installed. + +**Manual:** Confirm `VERCEL_TOKEN` is valid; push to `main` or open PR to trigger deploy. + +Phase complete — please run (/remember) to save tokens before proceeding to the next phase. + +## 2026-06-05 — Production Vercel + Supabase web checkout + +- **Vercel (`petfolio`):** `prj_hMHouLWimZvr5dDOlZeAhbH8xtop` · production domains `petfolio.live`, `www.petfolio.live`, `petfolio-woad.vercel.app`. +- **Supabase secrets:** `PUBLIC_APP_ORIGIN=https://petfolio.live`, `ALLOWED_REDIRECT_ORIGINS` (live + www + Vercel prod alias). +- **`create-payment-intent`:** redeployed (redirect URL allowlist). +- **`deploy-web.yml`:** production-only (`main` push + `workflow_dispatch`; no PR preview deploy). + +Phase complete — please run (/remember) to save tokens before proceeding to the next phase. + diff --git a/pubspec.yaml b/pubspec.yaml index f837e63..8b55a66 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -30,6 +30,8 @@ environment: dependencies: flutter: sdk: flutter + flutter_web_plugins: + sdk: flutter cupertino_icons: ^1.0.8 @@ -71,6 +73,9 @@ dependencies: geolocator: ^14.0.0 permission_handler: ^12.0.0 + firebase_core: ^3.15.2 + firebase_messaging: ^15.2.10 + # Local notifications flutter_local_notifications: ^21.0.0 timezone: ^0.11.0 diff --git a/supabase/.temp/cli-latest b/supabase/.temp/cli-latest index aff2b6e..f98a4ce 100644 --- a/supabase/.temp/cli-latest +++ b/supabase/.temp/cli-latest @@ -1 +1 @@ -v2.101.0 \ No newline at end of file +v2.105.0 \ No newline at end of file diff --git a/supabase/functions/_shared/fcm_send.ts b/supabase/functions/_shared/fcm_send.ts new file mode 100644 index 0000000..c83ef73 --- /dev/null +++ b/supabase/functions/_shared/fcm_send.ts @@ -0,0 +1,222 @@ +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.1"; + +const ANDROID_CHANNEL_PUSH = "petfolio_push"; +const ANDROID_CHANNEL_CHAT = "petfolio_chat_v2"; +const CHAT_SOUND = "chat_message"; + +function base64UrlEncode(bytes: Uint8Array): string { + let binary = ""; + for (const b of bytes) binary += String.fromCharCode(b); + return btoa(binary) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +function base64UrlEncodeJson(value: unknown): string { + return base64UrlEncode(new TextEncoder().encode(JSON.stringify(value))); +} + +async function getAccessToken(serviceAccount: { + client_email: string; + private_key: string; +}): Promise { + const now = Math.floor(Date.now() / 1000); + const header = base64UrlEncodeJson({ alg: "RS256", typ: "JWT" }); + const claim = { + iss: serviceAccount.client_email, + sub: serviceAccount.client_email, + aud: "https://oauth2.googleapis.com/token", + iat: now, + exp: now + 3600, + scope: "https://www.googleapis.com/auth/firebase.messaging", + }; + const payload = base64UrlEncodeJson(claim); + const unsigned = `${header}.${payload}`; + + const pem = serviceAccount.private_key.replace(/\\n/g, "\n"); + const keyData = pem + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replace(/\s/g, ""); + const binary = Uint8Array.from(atob(keyData), (c) => c.charCodeAt(0)); + const key = await crypto.subtle.importKey( + "pkcs8", + binary, + { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, + false, + ["sign"], + ); + const sig = await crypto.subtle.sign( + "RSASSA-PKCS1-v1_5", + key, + new TextEncoder().encode(unsigned), + ); + const signature = base64UrlEncode(new Uint8Array(sig)); + const jwt = `${unsigned}.${signature}`; + + const tokenRes = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", + assertion: jwt, + }), + }); + const tokenJson = await tokenRes.json(); + if (!tokenRes.ok) { + throw new Error( + (tokenJson as { error_description?: string }).error_description ?? + "OAuth token failed", + ); + } + return (tokenJson as { access_token: string }).access_token; +} + +function parseServiceAccount(): { + client_email: string; + private_key: string; + project_id: string; +} { + const raw = Deno.env.get("FIREBASE_SERVICE_ACCOUNT_JSON"); + if (!raw?.trim()) { + throw new Error("FIREBASE_SERVICE_ACCOUNT_JSON not set"); + } + let parsed: Record; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error("FIREBASE_SERVICE_ACCOUNT_JSON is invalid JSON"); + } + const client_email = parsed.client_email as string | undefined; + const private_key = parsed.private_key as string | undefined; + const project_id = parsed.project_id as string | undefined; + if (!client_email || !private_key || !project_id) { + throw new Error( + "FIREBASE_SERVICE_ACCOUNT_JSON missing client_email, private_key, or project_id", + ); + } + return { client_email, private_key, project_id }; +} + +function stringifyData(data?: Record): Record { + if (!data) return {}; + const out: Record = {}; + for (const [k, v] of Object.entries(data)) { + out[k] = typeof v === "string" ? v : JSON.stringify(v); + } + return out; +} + +function isUnregisteredTokenError(body: unknown): boolean { + if (!body || typeof body !== "object") return false; + const err = body as { error?: { details?: Array<{ errorCode?: string }> } }; + return (err.error?.details ?? []).some((d) => d.errorCode === "UNREGISTERED"); +} + +export async function sendFcmToUser( + userId: string, + title: string, + body: string, + data?: Record, +): Promise<{ sent: number; total: number; errors: string[] }> { + const serviceAccount = parseServiceAccount(); + const supabase = createClient( + Deno.env.get("SUPABASE_URL")!, + Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!, + ); + + const { data: devices, error } = await supabase + .from("user_fcm_devices") + .select("fcm_token") + .eq("user_id", userId); + + if (error) throw error; + const tokens = (devices ?? []).map((d) => d.fcm_token as string).filter(Boolean); + if (tokens.length === 0) return { sent: 0, total: 0, errors: [] }; + + const accessToken = await getAccessToken(serviceAccount); + const projectId = serviceAccount.project_id; + const isChat = data?.type === "chat_message"; + const fcmData = stringifyData( + isChat ? { ...data, title, body } : data, + ); + const androidChannelId = isChat ? ANDROID_CHANNEL_CHAT : ANDROID_CHANNEL_PUSH; + const androidSound = isChat ? CHAT_SOUND : "default"; + const apnsSound = isChat ? "chat_message.wav" : "default"; + + let sent = 0; + const errors: string[] = []; + + for (const token of tokens) { + const messagePayload: Record = { + token, + data: fcmData, + }; + + if (isChat) { + messagePayload.android = { priority: "HIGH" }; + messagePayload.apns = { + payload: { + aps: { + alert: { title, body }, + sound: apnsSound, + badge: 1, + }, + }, + }; + } else { + messagePayload.notification = { title, body }; + messagePayload.android = { + priority: "HIGH", + notification: { + channel_id: androidChannelId, + sound: androidSound, + }, + }; + messagePayload.apns = { + payload: { + aps: { + sound: apnsSound, + badge: 1, + }, + }, + }; + } + + const res = await fetch( + `https://fcm.googleapis.com/v1/projects/${projectId}/messages:send`, + { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ message: messagePayload }), + }, + ); + if (res.ok) { + sent += 1; + continue; + } + + const errBody = await res.json().catch(() => ({})); + const message = + (errBody as { error?: { message?: string } })?.error?.message ?? + `FCM HTTP ${res.status}`; + errors.push(message); + + if (isUnregisteredTokenError(errBody)) { + await supabase + .from("user_fcm_devices") + .delete() + .eq("fcm_token", token); + } + } + + if (sent === 0 && errors.length > 0) { + throw new Error(errors[0]); + } + + return { sent, total: tokens.length, errors }; +} diff --git a/supabase/functions/create-payment-intent/index.ts b/supabase/functions/create-payment-intent/index.ts index d978239..d5f4f8f 100644 --- a/supabase/functions/create-payment-intent/index.ts +++ b/supabase/functions/create-payment-intent/index.ts @@ -35,6 +35,37 @@ function json(body: unknown, status = 200): Response { }); } +function allowedRedirectOrigins(): string[] { + const raw = + Deno.env.get('ALLOWED_REDIRECT_ORIGINS') ?? Deno.env.get('PUBLIC_APP_ORIGIN') ?? ''; + return raw + .split(',') + .map((s) => s.trim()) + .filter(Boolean); +} + +function isAllowedRedirectUrl(url: string): boolean { + const origins = allowedRedirectOrigins(); + if (origins.length === 0) return false; + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return false; + } + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return false; + if (parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1') { + return true; + } + return origins.some((origin) => { + try { + return parsed.origin === new URL(origin).origin; + } catch { + return false; + } + }); +} + serve(async (req) => { if (req.method === 'OPTIONS') { return new Response('ok', { headers: corsHeaders }); @@ -57,9 +88,18 @@ serve(async (req) => { const body = await req.json() as { orderId?: string; payment_method?: string; + checkout_mode?: boolean; + success_url?: string; + cancel_url?: string; }; - const { orderId, payment_method = 'stripe' } = body; + const { + orderId, + payment_method = 'stripe', + checkout_mode = false, + success_url, + cancel_url, + } = body; if (!orderId) { return json({ error: 'orderId is required' }, 400); @@ -77,7 +117,7 @@ serve(async (req) => { const { data: order, error: orderErr } = await admin .from('marketplace_orders') - .select('id, amount_cents, currency, buyer_id, shop_id, stripe_payment_intent_id, line_items, status') + .select('id, amount_cents, currency, buyer_id, shop_id, stripe_payment_intent_id, stripe_checkout_session_id, line_items, status') .eq('id', orderId) .single(); @@ -179,6 +219,77 @@ serve(async (req) => { ); } + if (checkout_mode) { + if (!success_url || !cancel_url) { + return json({ error: 'success_url and cancel_url are required for checkout_mode' }, 400); + } + if (!isAllowedRedirectUrl(success_url) || !isAllowedRedirectUrl(cancel_url)) { + return json({ error: 'Redirect URL origin is not allowed' }, 400); + } + + if (order.stripe_checkout_session_id) { + const existingSession = await stripe.checkout.sessions.retrieve( + order.stripe_checkout_session_id, + ); + if (existingSession.url && existingSession.status === 'open') { + return json({ checkoutUrl: existingSession.url }); + } + } + + type SessionParams = Parameters[0]; + const paymentIntentData: NonNullable = { + metadata: { + order_id: order.id, + buyer_id: order.buyer_id, + shop_id: order.shop_id, + }, + }; + + if (isVendorShop) { + const applicationFeeAmount = Math.floor( + (order.amount_cents * Number(shop.platform_fee_percent)) / 100, + ); + paymentIntentData.application_fee_amount = applicationFeeAmount; + paymentIntentData.transfer_data = { + destination: shop.stripe_connect_account_id!, + }; + } + + const session = await stripe.checkout.sessions.create({ + mode: 'payment', + success_url, + cancel_url, + client_reference_id: order.id, + line_items: [ + { + price_data: { + currency: order.currency ?? 'usd', + unit_amount: order.amount_cents, + product_data: { name: 'PetFolio order' }, + }, + quantity: 1, + }, + ], + payment_intent_data: paymentIntentData, + }, { + idempotencyKey: `cs-${orderId}`, + }); + + await admin + .from('marketplace_orders') + .update({ + stripe_checkout_session_id: session.id, + payment_method: 'stripe', + }) + .eq('id', orderId); + + if (!session.url) { + return json({ error: 'Stripe Checkout session missing URL' }, 500); + } + + return json({ checkoutUrl: session.url }); + } + // Idempotency: return existing PI if one already exists. if (order.stripe_payment_intent_id) { const existing = await stripe.paymentIntents.retrieve(order.stripe_payment_intent_id); diff --git a/supabase/functions/process-care-fcm-reminders/index.ts b/supabase/functions/process-care-fcm-reminders/index.ts new file mode 100644 index 0000000..ddb3f5f --- /dev/null +++ b/supabase/functions/process-care-fcm-reminders/index.ts @@ -0,0 +1,91 @@ +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.1"; +import { sendFcmToUser } from "../_shared/fcm_send.ts"; + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": + "authorization, x-client-info, apikey, content-type, x-fcm-dispatch-secret", +}; + +function assertDispatchAuth(req: Request): boolean { + const expected = Deno.env.get("FCM_DISPATCH_SECRET"); + if (!expected) return false; + return req.headers.get("x-fcm-dispatch-secret") === expected; +} + +Deno.serve(async (req) => { + if (req.method === "OPTIONS") { + return new Response("ok", { headers: corsHeaders }); + } + + if (!assertDispatchAuth(req)) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + try { + const supabase = createClient( + Deno.env.get("SUPABASE_URL")!, + Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!, + ); + + const now = new Date().toISOString(); + const { data: due, error } = await supabase + .from("care_web_reminders") + .select("id, user_id, task_id, title, repeating, remind_at") + .lte("remind_at", now) + .is("fcm_sent_at", null) + .limit(50); + + if (error) throw error; + + let sent = 0; + for (const row of due ?? []) { + try { + const result = await sendFcmToUser( + row.user_id as string, + "Care reminder", + row.title as string, + { + type: "care_reminder", + task_id: String(row.task_id), + route: "/care", + }, + ); + if (result.sent > 0) { + if (row.repeating) { + const next = new Date(row.remind_at as string); + next.setUTCDate(next.getUTCDate() + 1); + await supabase + .from("care_web_reminders") + .update({ + remind_at: next.toISOString(), + fcm_sent_at: null, + updated_at: now, + }) + .eq("id", row.id); + } else { + await supabase + .from("care_web_reminders") + .update({ fcm_sent_at: now }) + .eq("id", row.id); + } + sent += result.sent; + } + } catch (_) { + continue; + } + } + + return new Response(JSON.stringify({ due: due?.length ?? 0, sent }), { + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } catch (e) { + return new Response(JSON.stringify({ error: String(e) }), { + status: 500, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } +}); diff --git a/supabase/functions/process-fcm-outbox/index.ts b/supabase/functions/process-fcm-outbox/index.ts new file mode 100644 index 0000000..56d69cd --- /dev/null +++ b/supabase/functions/process-fcm-outbox/index.ts @@ -0,0 +1,80 @@ +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.1"; +import { sendFcmToUser } from "../_shared/fcm_send.ts"; + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": + "authorization, x-client-info, apikey, content-type, x-fcm-dispatch-secret", +}; + +function assertDispatchAuth(req: Request): boolean { + const expected = Deno.env.get("FCM_DISPATCH_SECRET"); + if (!expected) return false; + return req.headers.get("x-fcm-dispatch-secret") === expected; +} + +Deno.serve(async (req) => { + if (req.method === "OPTIONS") { + return new Response("ok", { headers: corsHeaders }); + } + + if (!assertDispatchAuth(req)) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + try { + const supabase = createClient( + Deno.env.get("SUPABASE_URL")!, + Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!, + ); + + const { data: rows, error } = await supabase + .from("fcm_push_outbox") + .select("id, user_id, title, body, data") + .is("processed_at", null) + .order("created_at", { ascending: true }) + .limit(40); + + if (error) throw error; + + let processed = 0; + let sentTotal = 0; + + for (const row of rows ?? []) { + const raw = (row.data ?? {}) as Record; + const data: Record = {}; + for (const [k, v] of Object.entries(raw)) { + data[k] = typeof v === "string" ? v : JSON.stringify(v); + } + + try { + const result = await sendFcmToUser( + row.user_id as string, + row.title as string, + (row.body as string) ?? "", + data, + ); + sentTotal += result.sent; + await supabase + .from("fcm_push_outbox") + .update({ processed_at: new Date().toISOString() }) + .eq("id", row.id); + processed += 1; + } catch (_) { + continue; + } + } + + return new Response(JSON.stringify({ processed, sentTotal }), { + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } catch (e) { + return new Response(JSON.stringify({ error: String(e) }), { + status: 500, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } +}); diff --git a/supabase/functions/register-web-push-subscription/index.ts b/supabase/functions/register-web-push-subscription/index.ts new file mode 100644 index 0000000..7684706 --- /dev/null +++ b/supabase/functions/register-web-push-subscription/index.ts @@ -0,0 +1,72 @@ +import { serve } from 'https://deno.land/std@0.177.0/http/server.ts'; +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'; + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +}; + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); +} + +serve(async (req) => { + if (req.method === 'OPTIONS') { + return new Response('ok', { headers: corsHeaders }); + } + + try { + const supabase = createClient( + Deno.env.get('SUPABASE_URL') ?? '', + Deno.env.get('SUPABASE_ANON_KEY') ?? '', + { global: { headers: { Authorization: req.headers.get('Authorization') ?? '' } } }, + ); + + const { data: { user }, error: authErr } = await supabase.auth.getUser(); + if (authErr || !user) { + return json({ error: 'Unauthorized' }, 401); + } + + const body = await req.json() as { + endpoint?: string; + p256dh?: string; + auth?: string; + }; + + const { endpoint, p256dh, auth } = body; + if (!endpoint || !p256dh || !auth) { + return json({ error: 'endpoint, p256dh, and auth are required' }, 400); + } + + const admin = createClient( + Deno.env.get('SUPABASE_URL') ?? '', + Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '', + ); + + const { error } = await admin.from('user_web_push_subscriptions').upsert( + { + user_id: user.id, + endpoint, + p256dh, + auth, + }, + { onConflict: 'endpoint' }, + ); + + if (error) { + console.error('register-web-push-subscription:', error); + return json({ error: 'Failed to save subscription' }, 500); + } + + return json({ ok: true }); + } catch (err) { + console.error('register-web-push-subscription error:', err); + return json( + { error: err instanceof Error ? err.message : 'Internal error' }, + 500, + ); + } +}); diff --git a/supabase/functions/send-fcm-notification/index.ts b/supabase/functions/send-fcm-notification/index.ts new file mode 100644 index 0000000..8da8070 --- /dev/null +++ b/supabase/functions/send-fcm-notification/index.ts @@ -0,0 +1,59 @@ +import { sendFcmToUser } from "../_shared/fcm_send.ts"; + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": + "authorization, x-client-info, apikey, content-type, x-fcm-dispatch-secret", +}; + +type SendBody = { + userId: string; + title: string; + body: string; + data?: Record; +}; + +function assertDispatchAuth(req: Request): boolean { + const expected = Deno.env.get("FCM_DISPATCH_SECRET"); + if (!expected) return false; + return req.headers.get("x-fcm-dispatch-secret") === expected; +} + +Deno.serve(async (req) => { + if (req.method === "OPTIONS") { + return new Response("ok", { headers: corsHeaders }); + } + + if (!assertDispatchAuth(req)) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + try { + const body = (await req.json()) as SendBody; + if (!body.userId || !body.title) { + return new Response(JSON.stringify({ error: "userId and title required" }), { + status: 400, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + const result = await sendFcmToUser( + body.userId, + body.title, + body.body ?? "", + body.data, + ); + + return new Response(JSON.stringify(result), { + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } catch (e) { + return new Response(JSON.stringify({ error: String(e) }), { + status: 500, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } +}); diff --git a/supabase/functions/stripe-webhook/index.ts b/supabase/functions/stripe-webhook/index.ts index 8434fd9..af479ee 100644 --- a/supabase/functions/stripe-webhook/index.ts +++ b/supabase/functions/stripe-webhook/index.ts @@ -3,7 +3,8 @@ // Receives and verifies Stripe webhook events. No Supabase JWT — Stripe HMAC only. // // Two Stripe Dashboard endpoints should point at this URL: -// • Platform: payment_intent.succeeded, payment_intent.payment_failed +// • Platform: payment_intent.succeeded, payment_intent.payment_failed, +// checkout.session.completed, checkout.session.expired // → signed with STRIPE_WEBHOOK_SECRET // • Connect: account.updated (Listen to events on Connected accounts) // → signed with STRIPE_CONNECT_WEBHOOK_SECRET @@ -35,6 +36,8 @@ const CONNECT_EVENT_TYPES = new Set([ const PLATFORM_EVENT_TYPES = new Set([ 'payment_intent.succeeded', 'payment_intent.payment_failed', + 'checkout.session.completed', + 'checkout.session.expired', ]); type WebhookRoute = 'connect' | 'platform'; @@ -87,6 +90,81 @@ async function constructVerifiedEvent( } } +// ─── Shared order fulfillment (idempotent) ─────────────────────────────────── + +type SupabaseAdmin = ReturnType; + +async function fulfillPaidOrder( + admin: SupabaseAdmin, + orderId: string, + source: string, +): Promise { + const { data: orderRow, error: updateErr } = await admin + .from('marketplace_orders') + .update({ + status: 'processing', + payment_status: 'paid', + updated_at: new Date().toISOString(), + }) + .eq('id', orderId) + .eq('status', 'pending') + .select('id, shop_id, amount_cents') + .maybeSingle(); + + if (updateErr) { + console.error(`${source}: order update failed`, updateErr); + return new Response('DB update failed', { status: 500 }); + } + + if (!orderRow) { + console.log(`${source}: order ${orderId} already processed or not found`); + return null; + } + + console.log(`${source}: order ${orderId} → processing, payment_status → paid`); + + const { error: confirmErr } = await admin.rpc('confirm_order_inventory', { + p_order_id: orderId, + }); + if (confirmErr) { + console.error(`${source}: confirm_order_inventory failed (non-fatal)`, confirmErr); + } + + const { data: shop } = await admin + .from('shops') + .select('platform_fee_percent') + .eq('id', orderRow.shop_id) + .maybeSingle(); + + const feePercent = shop?.platform_fee_percent ?? 10; + const platformFeeCents = Math.floor((orderRow.amount_cents * feePercent) / 100); + const vendorEarningsCents = orderRow.amount_cents - platformFeeCents; + + const { error: ledgerErr } = await admin.from('vendor_ledgers').insert({ + shop_id: orderRow.shop_id, + order_id: orderId, + order_total_cents: orderRow.amount_cents, + platform_fee_cents: platformFeeCents, + vendor_earnings_cents: vendorEarningsCents, + status: 'pending_clearance', + }); + + if (ledgerErr) { + if ((ledgerErr as { code?: string }).code === '23505') { + console.warn(`${source}: ledger already exists for order ${orderId}`); + } else { + console.error(`${source}: ledger insert failed (non-fatal)`, ledgerErr); + } + } else { + console.log( + `${source}: ledger created for order ${orderId} ` + + `(vendor +${vendorEarningsCents}¢, platform +${platformFeeCents}¢)`, + ); + } + + return null; +} + // ─── Handler ────────────────────────────────────────────────────────────────── serve(async (req) => { @@ -168,103 +246,80 @@ serve(async (req) => { break; } - // ── Platform: payment confirmed ───────────────────────────────────────── case 'payment_intent.succeeded': { const pi = event.data.object as Stripe.PaymentIntent; const orderId = pi.metadata?.order_id; if (!orderId) { - // PaymentIntent not created by this app (e.g. Stripe test event). console.warn('payment_intent.succeeded: no order_id in metadata, skipping'); break; } - // Transition the order from pending → processing and mark payment paid. - // The .eq('status', 'pending') guard makes this update idempotent: - // if the webhook fires twice, the second call matches no row. - const { data: orderRow, error: updateErr } = await admin + const errResp = await fulfillPaidOrder(admin, orderId, 'payment_intent.succeeded'); + if (errResp) return errResp; + break; + } + + case 'checkout.session.completed': { + const session = event.data.object as Stripe.Checkout.Session; + if (session.payment_status !== 'paid') { + console.log( + `checkout.session.completed: session ${session.id} payment_status=${session.payment_status}, skipping`, + ); + break; + } + + const orderId = + session.client_reference_id ?? + (typeof session.metadata?.order_id === 'string' + ? session.metadata.order_id + : undefined); + + if (!orderId) { + console.warn('checkout.session.completed: no order id, skipping'); + break; + } + + const errResp = await fulfillPaidOrder(admin, orderId, 'checkout.session.completed'); + if (errResp) return errResp; + break; + } + + case 'checkout.session.expired': { + const session = event.data.object as Stripe.Checkout.Session; + const orderId = + session.client_reference_id ?? session.metadata?.order_id; + + if (!orderId) { + console.warn('checkout.session.expired: no order id, skipping'); + break; + } + + const { error } = await admin .from('marketplace_orders') .update({ - status: 'processing', - payment_status: 'paid', - updated_at: new Date().toISOString(), + status: 'cancelled', + updated_at: new Date().toISOString(), }) .eq('id', orderId) - .eq('status', 'pending') - .select('id, shop_id, amount_cents') - .maybeSingle(); + .eq('status', 'pending'); - if (updateErr) { - console.error('payment_intent.succeeded: order update failed', updateErr); + if (error) { + console.error('checkout.session.expired: DB update failed', error); return new Response('DB update failed', { status: 500 }); } - if (!orderRow) { - // Row not found or already transitioned — safe to acknowledge. - console.log( - `payment_intent.succeeded: order ${orderId} already processed or not found`, - ); - break; - } - - console.log( - `payment_intent.succeeded: order ${orderId} → processing, payment_status → paid`, - ); - - // Confirm the inventory reservation → decrement stock atomically. - const { error: confirmErr } = await admin.rpc('confirm_order_inventory', { + const { error: releaseErr } = await admin.rpc('release_order_inventory', { p_order_id: orderId, }); - if (confirmErr) { + if (releaseErr) { console.error( - 'payment_intent.succeeded: confirm_order_inventory failed (non-fatal)', - confirmErr, - ); - } - - // Create the vendor ledger entry so the payout flow can proceed. - // Resolve the shop's platform fee to calculate the split. - const { data: shop } = await admin - .from('shops') - .select('platform_fee_percent') - .eq('id', orderRow.shop_id) - .maybeSingle(); - - const feePercent = shop?.platform_fee_percent ?? 10; - const platformFeeCents = Math.floor((orderRow.amount_cents * feePercent) / 100); - const vendorEarningsCents = orderRow.amount_cents - platformFeeCents; - - const { error: ledgerErr } = await admin - .from('vendor_ledgers') - .insert({ - shop_id: orderRow.shop_id, - order_id: orderId, - order_total_cents: orderRow.amount_cents, - platform_fee_cents: platformFeeCents, - vendor_earnings_cents: vendorEarningsCents, - status: 'pending_clearance', - }); - - if (ledgerErr) { - // Code 23505 = unique_violation: ledger already exists (replayed webhook). - // Not fatal — the order transition succeeded; just log and continue. - if ((ledgerErr as { code?: string }).code === '23505') { - console.warn( - `payment_intent.succeeded: ledger already exists for order ${orderId}`, - ); - } else { - console.error( - 'payment_intent.succeeded: ledger insert failed (non-fatal)', - ledgerErr, - ); - } - } else { - console.log( - `payment_intent.succeeded: ledger created for order ${orderId} ` + - `(vendor +${vendorEarningsCents}¢, platform +${platformFeeCents}¢)`, + 'checkout.session.expired: release_order_inventory failed (non-fatal)', + releaseErr, ); } + console.log(`checkout.session.expired: order ${orderId} cancelled`); break; } diff --git a/supabase/migrations/20260604000000_fix_matching_discovery_candidates_offset_signature.sql b/supabase/migrations/20260604000000_fix_matching_discovery_candidates_offset_signature.sql index 911d7d3..d9a85bf 100644 --- a/supabase/migrations/20260604000000_fix_matching_discovery_candidates_offset_signature.sql +++ b/supabase/migrations/20260604000000_fix_matching_discovery_candidates_offset_signature.sql @@ -1,111 +1,4 @@ --- The cursor-based overload (p_cursor_created_at, p_cursor_pet_id) was applied --- directly to the DB outside version control, causing a signature mismatch with --- the Dart client (MatchingSupabaseDataSource) which passes p_offset. --- Drop the stale overload and restore the offset-based version with correct grants. +-- Superseded by 20260605120000_restore_matching_discovery_candidates_cursor.sql. +-- The offset-based overload broke the Dart client (cursor RPC params). No-op here. -DROP FUNCTION IF EXISTS public.matching_discovery_candidates( - uuid, - double precision, - integer, - timestamp with time zone, - uuid, - text[], - integer, - integer -); - -CREATE OR REPLACE FUNCTION public.matching_discovery_candidates( - p_actor_pet_id uuid, - p_radius_meters double precision DEFAULT 80467, - p_limit integer DEFAULT 20, - p_offset integer DEFAULT 0, - p_species text[] DEFAULT NULL, - p_min_age_years integer DEFAULT NULL, - p_max_age_years integer DEFAULT NULL -) -RETURNS TABLE ( - id uuid, - owner_id uuid, - name text, - species text, - breed text, - date_of_birth date, - avatar_url text, - bio text, - distance_meters double precision, - is_discoverable boolean, - owner jsonb -) -LANGUAGE sql -STABLE -SECURITY DEFINER -SET search_path = public, extensions -AS $$ - WITH origin AS ( - SELECT p.location AS loc, p.owner_id - FROM public.pets p - WHERE p.id = p_actor_pet_id - AND (SELECT auth.uid()) = p.owner_id - AND p.is_discoverable IS TRUE - ) - SELECT - c.id, - c.owner_id, - c.name, - c.species, - c.breed, - c.date_of_birth, - c.avatar_url, - c.bio, - ST_Distance(o.loc, c.location)::double precision AS distance_meters, - c.is_discoverable, - ( - SELECT jsonb_build_object( - 'id', u.id, - 'username', u.username, - 'display_name', u.display_name - ) - FROM public.users u - WHERE u.id = c.owner_id - ) AS owner - FROM origin o - CROSS JOIN public.pets c - LEFT JOIN public.swipes s - ON s.actor_id = p_actor_pet_id - AND s.target_id = c.id - WHERE c.id != p_actor_pet_id - AND c.owner_id != o.owner_id - AND c.is_public IS TRUE - AND c.is_discoverable IS TRUE - AND c.archived_at IS NULL - AND c.location IS NOT NULL - AND o.loc IS NOT NULL - AND ST_DWithin(o.loc, c.location, p_radius_meters) - AND s.id IS NULL - AND ( - p_species IS NULL - OR cardinality(p_species) = 0 - OR EXISTS ( - SELECT 1 - FROM unnest(p_species) AS u(species_text) - WHERE lower(trim(u.species_text)) = lower(trim(c.species)) - ) - ) - AND ( - c.date_of_birth IS NULL - OR ( - (p_min_age_years IS NULL - OR date_part('year', age(current_date, c.date_of_birth))::int >= p_min_age_years) - AND - (p_max_age_years IS NULL - OR date_part('year', age(current_date, c.date_of_birth))::int <= p_max_age_years) - ) - ) - ORDER BY c.created_at DESC - OFFSET greatest(coalesce(p_offset, 0), 0) - LIMIT greatest(coalesce(nullif(p_limit, 0), 20), 1); -$$; - -REVOKE ALL ON FUNCTION public.matching_discovery_candidates(uuid, double precision, integer, integer, text[], integer, integer) FROM PUBLIC; -GRANT EXECUTE ON FUNCTION public.matching_discovery_candidates(uuid, double precision, integer, integer, text[], integer, integer) TO authenticated; -GRANT EXECUTE ON FUNCTION public.matching_discovery_candidates(uuid, double precision, integer, integer, text[], integer, integer) TO service_role; +SELECT 1; diff --git a/supabase/migrations/20260605100000_pwa_phase2.sql b/supabase/migrations/20260605100000_pwa_phase2.sql new file mode 100644 index 0000000..7eea460 --- /dev/null +++ b/supabase/migrations/20260605100000_pwa_phase2.sql @@ -0,0 +1,72 @@ +ALTER TABLE public.marketplace_orders + ADD COLUMN IF NOT EXISTS stripe_checkout_session_id text; + +CREATE UNIQUE INDEX IF NOT EXISTS marketplace_orders_stripe_cs_idx + ON public.marketplace_orders (stripe_checkout_session_id) + WHERE stripe_checkout_session_id IS NOT NULL; + +CREATE TABLE IF NOT EXISTS public.care_web_reminders ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES auth.users (id) ON DELETE CASCADE, + task_id text NOT NULL, + title text NOT NULL, + remind_at timestamptz NOT NULL, + repeating boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (user_id, task_id) +); + +CREATE INDEX IF NOT EXISTS care_web_reminders_fire_idx + ON public.care_web_reminders (remind_at); + +ALTER TABLE public.care_web_reminders ENABLE ROW LEVEL SECURITY; + +CREATE POLICY care_web_reminders_select_own ON public.care_web_reminders + FOR SELECT TO authenticated + USING (user_id = (SELECT auth.uid())); + +CREATE POLICY care_web_reminders_insert_own ON public.care_web_reminders + FOR INSERT TO authenticated + WITH CHECK (user_id = (SELECT auth.uid())); + +CREATE POLICY care_web_reminders_update_own ON public.care_web_reminders + FOR UPDATE TO authenticated + USING (user_id = (SELECT auth.uid())) + WITH CHECK (user_id = (SELECT auth.uid())); + +CREATE POLICY care_web_reminders_delete_own ON public.care_web_reminders + FOR DELETE TO authenticated + USING (user_id = (SELECT auth.uid())); + +CREATE TABLE IF NOT EXISTS public.user_web_push_subscriptions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES auth.users (id) ON DELETE CASCADE, + endpoint text NOT NULL, + p256dh text NOT NULL, + auth text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (endpoint) +); + +CREATE INDEX IF NOT EXISTS user_web_push_subscriptions_user_idx + ON public.user_web_push_subscriptions (user_id); + +ALTER TABLE public.user_web_push_subscriptions ENABLE ROW LEVEL SECURITY; + +CREATE POLICY user_web_push_subscriptions_select_own ON public.user_web_push_subscriptions + FOR SELECT TO authenticated + USING (user_id = (SELECT auth.uid())); + +CREATE POLICY user_web_push_subscriptions_insert_own ON public.user_web_push_subscriptions + FOR INSERT TO authenticated + WITH CHECK (user_id = (SELECT auth.uid())); + +CREATE POLICY user_web_push_subscriptions_update_own ON public.user_web_push_subscriptions + FOR UPDATE TO authenticated + USING (user_id = (SELECT auth.uid())) + WITH CHECK (user_id = (SELECT auth.uid())); + +CREATE POLICY user_web_push_subscriptions_delete_own ON public.user_web_push_subscriptions + FOR DELETE TO authenticated + USING (user_id = (SELECT auth.uid())); diff --git a/supabase/migrations/20260605120000_restore_matching_discovery_candidates_cursor.sql b/supabase/migrations/20260605120000_restore_matching_discovery_candidates_cursor.sql new file mode 100644 index 0000000..1f13c49 --- /dev/null +++ b/supabase/migrations/20260605120000_restore_matching_discovery_candidates_cursor.sql @@ -0,0 +1,134 @@ +DROP FUNCTION IF EXISTS public.matching_discovery_candidates( + uuid, + double precision, + integer, + integer, + text[], + integer, + integer +); + +DROP FUNCTION IF EXISTS public.matching_discovery_candidates( + uuid, + double precision, + integer, + timestamptz, + uuid, + text[], + integer, + integer +); + +CREATE OR REPLACE FUNCTION public.matching_discovery_candidates( + p_actor_pet_id uuid, + p_radius_meters double precision DEFAULT 80467, + p_limit integer DEFAULT 20, + p_cursor_created_at timestamptz DEFAULT NULL, + p_cursor_pet_id uuid DEFAULT NULL, + p_species text[] DEFAULT NULL, + p_min_age_years integer DEFAULT NULL, + p_max_age_years integer DEFAULT NULL +) +RETURNS TABLE ( + id uuid, + owner_id uuid, + name text, + species text, + breed text, + date_of_birth date, + avatar_url text, + bio text, + distance_meters double precision, + is_discoverable boolean, + created_at timestamptz, + owner jsonb +) +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = public, extensions +AS $$ + WITH origin AS ( + SELECT p.location AS loc, p.owner_id + FROM public.pets p + WHERE p.id = p_actor_pet_id + AND (select auth.uid()) = p.owner_id + AND p.is_discoverable IS TRUE + ) + SELECT + c.id, + c.owner_id, + c.name, + c.species, + c.breed, + c.date_of_birth, + c.avatar_url, + c.bio, + ST_Distance(o.loc, c.location)::double precision AS distance_meters, + c.is_discoverable, + c.created_at, + owner_sub.owner_json AS owner + FROM origin o + CROSS JOIN public.pets c + LEFT JOIN public.swipes s + ON s.actor_id = p_actor_pet_id + AND s.target_id = c.id + LEFT JOIN LATERAL ( + SELECT jsonb_build_object( + 'id', u.id, + 'username', u.username, + 'display_name', u.display_name + ) AS owner_json + FROM public.users u + WHERE u.id = c.owner_id + LIMIT 1 + ) owner_sub ON true + WHERE NOT (c.id = p_actor_pet_id) + AND c.owner_id != o.owner_id + AND c.is_public IS TRUE + AND c.is_discoverable IS TRUE + AND c.archived_at IS NULL + AND c.location IS NOT NULL + AND o.loc IS NOT NULL + AND ST_DWithin(o.loc, c.location, p_radius_meters) + AND s.id IS NULL + AND ( + p_cursor_created_at IS NULL + OR c.created_at < p_cursor_created_at + OR (c.created_at = p_cursor_created_at AND c.id < p_cursor_pet_id) + ) + AND ( + p_species IS NULL + OR cardinality(p_species) = 0 + OR lower(trim(c.species)) = ANY( + SELECT lower(trim(s2)) FROM unnest(p_species) s2 + ) + ) + AND ( + c.date_of_birth IS NULL + OR ( + (p_min_age_years IS NULL + OR date_part('year', age(current_date, c.date_of_birth))::int >= p_min_age_years) + AND (p_max_age_years IS NULL + OR date_part('year', age(current_date, c.date_of_birth))::int <= p_max_age_years) + ) + ) + ORDER BY c.created_at DESC, c.id DESC + LIMIT greatest(coalesce(nullif(p_limit, 0), 20), 1); +$$; + +REVOKE ALL ON FUNCTION public.matching_discovery_candidates( + uuid, double precision, integer, timestamptz, uuid, text[], integer, integer +) FROM PUBLIC; + +REVOKE EXECUTE ON FUNCTION public.matching_discovery_candidates( + uuid, double precision, integer, timestamptz, uuid, text[], integer, integer +) FROM anon; + +GRANT EXECUTE ON FUNCTION public.matching_discovery_candidates( + uuid, double precision, integer, timestamptz, uuid, text[], integer, integer +) TO authenticated; + +GRANT EXECUTE ON FUNCTION public.matching_discovery_candidates( + uuid, double precision, integer, timestamptz, uuid, text[], integer, integer +) TO service_role; diff --git a/supabase/migrations/20260605130000_waitlist_insert_policy_hardening.sql b/supabase/migrations/20260605130000_waitlist_insert_policy_hardening.sql new file mode 100644 index 0000000..aeb4ac4 --- /dev/null +++ b/supabase/migrations/20260605130000_waitlist_insert_policy_hardening.sql @@ -0,0 +1,9 @@ +DROP POLICY IF EXISTS allow_public_insert ON public.waitlist; + +CREATE POLICY waitlist_public_insert ON public.waitlist + FOR INSERT TO anon, authenticated + WITH CHECK ( + email IS NOT NULL + AND length(trim(email)) >= 5 + AND email ~* '^[^@\s]+@[^@\s]+\.[^@\s]+$' + ); diff --git a/supabase/migrations/20260606120000_user_fcm_devices.sql b/supabase/migrations/20260606120000_user_fcm_devices.sql new file mode 100644 index 0000000..5059480 --- /dev/null +++ b/supabase/migrations/20260606120000_user_fcm_devices.sql @@ -0,0 +1,30 @@ +CREATE TABLE IF NOT EXISTS public.user_fcm_devices ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES auth.users (id) ON DELETE CASCADE, + fcm_token text NOT NULL, + platform text NOT NULL CHECK (platform IN ('android', 'ios', 'web')), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (fcm_token) +); + +CREATE INDEX IF NOT EXISTS user_fcm_devices_user_idx + ON public.user_fcm_devices (user_id); + +ALTER TABLE public.user_fcm_devices ENABLE ROW LEVEL SECURITY; + +CREATE POLICY user_fcm_devices_select_own ON public.user_fcm_devices + FOR SELECT TO authenticated + USING (user_id = (SELECT auth.uid())); + +CREATE POLICY user_fcm_devices_insert_own ON public.user_fcm_devices + FOR INSERT TO authenticated + WITH CHECK (user_id = (SELECT auth.uid())); + +CREATE POLICY user_fcm_devices_update_own ON public.user_fcm_devices + FOR UPDATE TO authenticated + USING (user_id = (SELECT auth.uid())) + WITH CHECK (user_id = (SELECT auth.uid())); + +CREATE POLICY user_fcm_devices_delete_own ON public.user_fcm_devices + FOR DELETE TO authenticated + USING (user_id = (SELECT auth.uid())); diff --git a/supabase/migrations/20260607140000_fcm_dispatch_system.sql b/supabase/migrations/20260607140000_fcm_dispatch_system.sql new file mode 100644 index 0000000..9055a11 --- /dev/null +++ b/supabase/migrations/20260607140000_fcm_dispatch_system.sql @@ -0,0 +1,402 @@ +CREATE EXTENSION IF NOT EXISTS pg_net WITH SCHEMA extensions; + +CREATE TABLE IF NOT EXISTS private.fcm_internal_config ( + id int PRIMARY KEY DEFAULT 1 CHECK (id = 1), + functions_base_url text NOT NULL DEFAULT 'https://jqyjvhwlcqcsuwcqgcwf.supabase.co/functions/v1', + dispatch_secret text +); + +INSERT INTO private.fcm_internal_config (id) +VALUES (1) +ON CONFLICT (id) DO NOTHING; + +CREATE TABLE IF NOT EXISTS public.fcm_push_outbox ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES auth.users (id) ON DELETE CASCADE, + title text NOT NULL, + body text NOT NULL, + data jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + processed_at timestamptz +); + +CREATE INDEX IF NOT EXISTS fcm_push_outbox_pending_idx + ON public.fcm_push_outbox (created_at) + WHERE processed_at IS NULL; + +ALTER TABLE public.fcm_push_outbox ENABLE ROW LEVEL SECURITY; + +ALTER TABLE public.care_web_reminders + ADD COLUMN IF NOT EXISTS fcm_sent_at timestamptz; + +CREATE OR REPLACE FUNCTION private.fcm_data_to_text_map(p_data jsonb) +RETURNS jsonb +LANGUAGE sql +IMMUTABLE +AS $$ + SELECT COALESCE( + ( + SELECT jsonb_object_agg(key, value) + FROM jsonb_each_text(COALESCE(p_data, '{}'::jsonb)) + ), + '{}'::jsonb + ); +$$; + +CREATE OR REPLACE FUNCTION private.dispatch_fcm_push( + p_user_id uuid, + p_title text, + p_body text, + p_data jsonb DEFAULT '{}'::jsonb +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public, private, extensions +AS $$ +DECLARE + v_base_url text; + v_secret text; + v_payload jsonb; +BEGIN + IF p_user_id IS NULL OR btrim(COALESCE(p_title, '')) = '' THEN + RETURN; + END IF; + + SELECT functions_base_url, dispatch_secret + INTO v_base_url, v_secret + FROM private.fcm_internal_config + WHERE id = 1; + + v_payload := jsonb_build_object( + 'userId', p_user_id::text, + 'title', p_title, + 'body', COALESCE(p_body, ''), + 'data', private.fcm_data_to_text_map(p_data) + ); + + IF v_secret IS NULL OR btrim(v_secret) = '' THEN + INSERT INTO public.fcm_push_outbox (user_id, title, body, data) + VALUES (p_user_id, p_title, COALESCE(p_body, ''), COALESCE(p_data, '{}'::jsonb)); + RETURN; + END IF; + + BEGIN + PERFORM net.http_post( + url := rtrim(v_base_url, '/') || '/send-fcm-notification', + headers := jsonb_build_object( + 'Content-Type', 'application/json', + 'x-fcm-dispatch-secret', v_secret + ), + body := v_payload + ); + EXCEPTION WHEN OTHERS THEN + INSERT INTO public.fcm_push_outbox (user_id, title, body, data) + VALUES (p_user_id, p_title, COALESCE(p_body, ''), COALESCE(p_data, '{}'::jsonb)); + END; +END; +$$; + +REVOKE ALL ON FUNCTION private.dispatch_fcm_push(uuid, text, text, jsonb) FROM PUBLIC; + +CREATE OR REPLACE FUNCTION private.handle_notification_fcm_push() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public, private +AS $$ +DECLARE + v_user_id uuid; + v_actor_handle text; + v_actor_name text; + v_title text; + v_body text; + v_data jsonb; +BEGIN + IF NEW.recipient_user_id IS NOT NULL THEN + v_user_id := NEW.recipient_user_id; + ELSIF NEW.recipient_pet_id IS NOT NULL THEN + SELECT owner_id INTO v_user_id FROM public.pets WHERE id = NEW.recipient_pet_id; + END IF; + + IF v_user_id IS NULL THEN + RETURN NEW; + END IF; + + IF NEW.actor_pet_id IS NOT NULL THEN + SELECT handle, name + INTO v_actor_handle, v_actor_name + FROM public.pets + WHERE id = NEW.actor_pet_id; + END IF; + + v_actor_handle := COALESCE(v_actor_handle, 'someone'); + v_actor_name := COALESCE(v_actor_name, 'A pet'); + + v_data := jsonb_build_object('type', NEW.type); + IF NEW.post_id IS NOT NULL THEN + v_data := v_data || jsonb_build_object('post_id', NEW.post_id::text); + END IF; + + CASE NEW.type + WHEN 'like' THEN + v_title := 'New like'; + v_body := '@' || v_actor_handle || ' liked your post.'; + v_data := v_data || jsonb_build_object('route', '/social/notifications'); + WHEN 'comment' THEN + v_title := 'New comment'; + v_body := '@' || v_actor_handle || ' commented on your post.'; + IF NEW.post_id IS NOT NULL THEN + v_data := v_data || jsonb_build_object('route', '/social/post/' || NEW.post_id::text); + END IF; + WHEN 'follow' THEN + v_title := 'New follower'; + v_body := '@' || v_actor_handle || ' started following you.'; + v_data := v_data || jsonb_build_object('route', '/social/notifications'); + WHEN 'kyc_approved' THEN + v_title := 'Shop verified'; + v_body := 'Your seller verification was approved.'; + v_data := v_data || jsonb_build_object('route', '/seller'); + WHEN 'kyc_rejected' THEN + v_title := 'Verification update'; + v_body := 'Your seller verification needs attention.'; + v_data := v_data || jsonb_build_object('route', '/seller/kyc'); + ELSE + v_title := 'PetFolio'; + v_body := v_actor_name || ' interacted with you.'; + v_data := v_data || jsonb_build_object('route', '/social/notifications'); + END CASE; + + PERFORM private.dispatch_fcm_push(v_user_id, v_title, v_body, v_data); + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_notification_fcm_push ON public.notifications; +CREATE TRIGGER trg_notification_fcm_push + AFTER INSERT ON public.notifications + FOR EACH ROW + EXECUTE FUNCTION private.handle_notification_fcm_push(); + +CREATE OR REPLACE FUNCTION private.handle_chat_message_fcm_push() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public, private +AS $$ +DECLARE + v_recipient uuid; + v_sender_handle text; + v_preview text; +BEGIN + SELECT + CASE + WHEN t.participant_1_id = NEW.sender_id THEN t.participant_2_id + ELSE t.participant_1_id + END + INTO v_recipient + FROM public.chat_threads t + WHERE t.id = NEW.thread_id; + + IF v_recipient IS NULL OR v_recipient = NEW.sender_id THEN + RETURN NEW; + END IF; + + SELECT COALESCE('@' || p.handle, 'Someone') + INTO v_sender_handle + FROM public.pets p + WHERE p.owner_id = NEW.sender_id + ORDER BY p.id + LIMIT 1; + + v_preview := left(regexp_replace(NEW.content, '\s+', ' ', 'g'), 120); + + PERFORM private.dispatch_fcm_push( + v_recipient, + 'New message', + COALESCE(v_sender_handle, 'Someone') || ': ' || v_preview, + jsonb_build_object( + 'type', 'chat_message', + 'thread_id', NEW.thread_id::text, + 'route', '/matching/chat/' || NEW.thread_id::text + ) + ); + + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_chat_message_fcm_push ON public.chat_messages; +CREATE TRIGGER trg_chat_message_fcm_push + AFTER INSERT ON public.chat_messages + FOR EACH ROW + EXECUTE FUNCTION private.handle_chat_message_fcm_push(); + +CREATE OR REPLACE FUNCTION private.handle_match_fcm_push() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public, private +AS $$ +DECLARE + v_owner_a uuid; + v_owner_b uuid; + v_name_a text; + v_name_b text; +BEGIN + SELECT owner_id, name INTO v_owner_a, v_name_a FROM public.pets WHERE id = NEW.pet_a_id; + SELECT owner_id, name INTO v_owner_b, v_name_b FROM public.pets WHERE id = NEW.pet_b_id; + + IF v_owner_a IS NOT NULL AND v_owner_a IS DISTINCT FROM v_owner_b THEN + PERFORM private.dispatch_fcm_push( + v_owner_a, + 'New match!', + 'You matched with ' || COALESCE(v_name_b, 'a pet') || '.', + jsonb_build_object('type', 'match', 'route', '/matching/inbox') + ); + END IF; + + IF v_owner_b IS NOT NULL AND v_owner_b IS DISTINCT FROM v_owner_a THEN + PERFORM private.dispatch_fcm_push( + v_owner_b, + 'New match!', + 'You matched with ' || COALESCE(v_name_a, 'a pet') || '.', + jsonb_build_object('type', 'match', 'route', '/matching/inbox') + ); + END IF; + + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_match_fcm_push ON public.matches; +CREATE TRIGGER trg_match_fcm_push + AFTER INSERT ON public.matches + FOR EACH ROW + EXECUTE FUNCTION private.handle_match_fcm_push(); + +CREATE OR REPLACE FUNCTION private.handle_order_status_fcm_push() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public, private +AS $$ +DECLARE + v_shop_name text; + v_seller_id uuid; +BEGIN + IF NEW.status IS NOT DISTINCT FROM OLD.status THEN + RETURN NEW; + END IF; + + IF NEW.shop_id IS NOT NULL THEN + SELECT s.name, s.owner_id INTO v_shop_name, v_seller_id + FROM public.shops s + WHERE s.id = NEW.shop_id; + END IF; + v_seller_id := COALESCE(v_seller_id, NEW.seller_id); + v_shop_name := COALESCE(v_shop_name, 'your shop'); + + IF NEW.status = 'processing' + AND OLD.status IS DISTINCT FROM 'processing' + AND NEW.buyer_id IS NOT NULL THEN + PERFORM private.dispatch_fcm_push( + NEW.buyer_id, + 'Order confirmed', + 'Your order is being prepared.', + jsonb_build_object( + 'type', 'order', + 'order_id', NEW.id::text, + 'route', '/profile/orders/' || NEW.id::text + ) + ); + IF v_seller_id IS NOT NULL THEN + PERFORM private.dispatch_fcm_push( + v_seller_id, + 'New order', + 'You have a new order at ' || v_shop_name || '.', + jsonb_build_object( + 'type', 'seller_order', + 'order_id', NEW.id::text, + 'route', '/seller/orders/' || NEW.id::text + ) + ); + END IF; + ELSIF NEW.status = 'shipped' + AND OLD.status IS DISTINCT FROM 'shipped' + AND NEW.buyer_id IS NOT NULL THEN + PERFORM private.dispatch_fcm_push( + NEW.buyer_id, + 'Order shipped', + 'Your order is on the way.', + jsonb_build_object( + 'type', 'order', + 'order_id', NEW.id::text, + 'route', '/profile/orders/' || NEW.id::text + ) + ); + ELSIF NEW.status = 'delivered' + AND OLD.status IS DISTINCT FROM 'delivered' + AND NEW.buyer_id IS NOT NULL THEN + PERFORM private.dispatch_fcm_push( + NEW.buyer_id, + 'Order delivered', + 'Your order was delivered.', + jsonb_build_object( + 'type', 'order', + 'order_id', NEW.id::text, + 'route', '/profile/orders/' || NEW.id::text + ) + ); + END IF; + + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_order_status_fcm_push ON public.marketplace_orders; +CREATE TRIGGER trg_order_status_fcm_push + AFTER UPDATE OF status ON public.marketplace_orders + FOR EACH ROW + EXECUTE FUNCTION private.handle_order_status_fcm_push(); + +DO $$ +DECLARE + r record; +BEGIN + FOR r IN SELECT jobid FROM cron.job WHERE jobname IN ('process_fcm_outbox', 'process_care_fcm_reminders') + LOOP + PERFORM cron.unschedule(r.jobid); + END LOOP; +END; +$$; + +SELECT cron.schedule( + 'process_fcm_outbox', + '* * * * *', + $$ + SELECT net.http_post( + url := (SELECT rtrim(functions_base_url, '/') || '/process-fcm-outbox' FROM private.fcm_internal_config WHERE id = 1), + headers := jsonb_build_object( + 'Content-Type', 'application/json', + 'x-fcm-dispatch-secret', (SELECT dispatch_secret FROM private.fcm_internal_config WHERE id = 1) + ), + body := '{}'::jsonb + ); + $$ +); + +SELECT cron.schedule( + 'process_care_fcm_reminders', + '*/5 * * * *', + $$ + SELECT net.http_post( + url := (SELECT rtrim(functions_base_url, '/') || '/process-care-fcm-reminders' FROM private.fcm_internal_config WHERE id = 1), + headers := jsonb_build_object( + 'Content-Type', 'application/json', + 'x-fcm-dispatch-secret', (SELECT dispatch_secret FROM private.fcm_internal_config WHERE id = 1) + ), + body := '{}'::jsonb + ); + $$ +); diff --git a/test/core/firebase/fcm_message_router_test.dart b/test/core/firebase/fcm_message_router_test.dart new file mode 100644 index 0000000..0a0a35f --- /dev/null +++ b/test/core/firebase/fcm_message_router_test.dart @@ -0,0 +1,60 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:petfolio/core/firebase/fcm_message_router.dart'; + +void main() { + group('FcmMessageRouter', () { + test('uses explicit route when provided', () { + expect( + FcmMessageRouter.routeFromData({'route': '/care'}), + '/care', + ); + }); + + test('maps chat_message to thread path', () { + expect( + FcmMessageRouter.routeFromData({ + 'type': 'chat_message', + 'thread_id': 'abc-123', + }), + '/matching/chat/abc-123', + ); + }); + + test('maps like to post when post_id present', () { + expect( + FcmMessageRouter.routeFromData({ + 'type': 'like', + 'post_id': 'post-1', + }), + '/social/post/post-1', + ); + }); + + test('maps order to buyer order detail', () { + expect( + FcmMessageRouter.routeFromData({ + 'type': 'order', + 'order_id': 'ord-9', + }), + '/profile/orders/ord-9', + ); + }); + + test('maps kyc_approved to seller dashboard', () { + expect( + FcmMessageRouter.routeFromData({'type': 'kyc_approved'}), + '/seller', + ); + }); + + test('usePushForPath for chat and inbox', () { + expect( + FcmMessageRouter.usePushForPath('/matching/chat/t1'), + isTrue, + ); + expect(FcmMessageRouter.usePushForPath('/matching/inbox'), isTrue); + expect(FcmMessageRouter.usePushForPath('/care'), isFalse); + expect(FcmMessageRouter.usePushForPath('/seller'), isFalse); + }); + }); +} diff --git a/tool/set_fcm_supabase_secrets.ps1 b/tool/set_fcm_supabase_secrets.ps1 new file mode 100644 index 0000000..d4b8e3b --- /dev/null +++ b/tool/set_fcm_supabase_secrets.ps1 @@ -0,0 +1,53 @@ +param( + [string]$ServiceAccountPath = "", + [string]$ProjectRef = "jqyjvhwlcqcsuwcqgcwf", + [string]$DispatchSecretPath = "" +) + +$ErrorActionPreference = "Stop" + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +if ([string]::IsNullOrWhiteSpace($ServiceAccountPath)) { + $candidates = Get-ChildItem $repoRoot -Filter "petfolio-v1-firebase-adminsdk*.json" -File | Select-Object -First 1 + if ($null -eq $candidates) { + throw "No petfolio-v1-firebase-adminsdk*.json in repo root. Pass -ServiceAccountPath." + } + $ServiceAccountPath = $candidates.FullName +} + +if (-not (Test-Path $ServiceAccountPath)) { + throw "Service account file not found: $ServiceAccountPath" +} + +$parsed = Get-Content $ServiceAccountPath -Raw | ConvertFrom-Json +$minified = $parsed | ConvertTo-Json -Compress -Depth 20 +$null = $minified | ConvertFrom-Json + +$envFile = Join-Path $repoRoot "supabase\.secrets.fcm.env" +$lines = @("FIREBASE_SERVICE_ACCOUNT_JSON=$minified") + +if ($DispatchSecretPath -and (Test-Path $DispatchSecretPath)) { + Get-Content $DispatchSecretPath | ForEach-Object { $lines += $_ } +} elseif (Test-Path (Join-Path $repoRoot ".env")) { + $dispatch = Get-Content (Join-Path $repoRoot ".env") | + Where-Object { $_ -match '^FCM_DISPATCH_SECRET=' } + if ($dispatch) { $lines += $dispatch } +} + +$lines | Set-Content $envFile -Encoding utf8 + +Push-Location $repoRoot +try { + npx supabase secrets set --env-file "supabase/.secrets.fcm.env" --project-ref $ProjectRef + $dispatchLine = $lines | Where-Object { $_ -match '^FCM_DISPATCH_SECRET=' } | Select-Object -First 1 + if ($dispatchLine) { + $secret = ($dispatchLine -replace '^FCM_DISPATCH_SECRET=', '').Trim() + $escaped = $secret.Replace("'", "''") + $sql = "UPDATE private.fcm_internal_config SET dispatch_secret = '$escaped' WHERE id = 1;" + npx supabase db query --linked $sql 2>&1 | Out-Host + } + Write-Host "Supabase FCM secrets and dispatch_secret synced." +} +finally { + Pop-Location +} diff --git a/tool/sync_firebase_from_cli.ps1 b/tool/sync_firebase_from_cli.ps1 new file mode 100644 index 0000000..1a68850 --- /dev/null +++ b/tool/sync_firebase_from_cli.ps1 @@ -0,0 +1,33 @@ +param( + [string]$Project = "petfolio-v1" +) + +$ErrorActionPreference = "Stop" +Set-Location (Split-Path $PSScriptRoot -Parent) + +Write-Host "Using Firebase project: $Project" +firebase use $Project + +Write-Host "Regenerating lib/firebase_options.dart (FlutterFire)..." +flutterfire configure --project=$Project --yes --platforms=android,web --out=lib/firebase_options.dart + +$androidOut = "android/app/google-services.json" +if (Test-Path $androidOut) { Remove-Item $androidOut -Force } +firebase apps:sdkconfig ANDROID --project $Project -o $androidOut +Write-Host "Wrote $androidOut" + +$webJson = firebase apps:sdkconfig WEB --project $Project | Out-String +$web = $webJson | ConvertFrom-Json +$js = @" +self.FIREBASE_WEB_CONFIG = { + apiKey: '$($web.apiKey)', + authDomain: '$($web.authDomain)', + projectId: '$($web.projectId)', + storageBucket: '$($web.storageBucket)', + messagingSenderId: '$($web.messagingSenderId)', + appId: '$($web.appId)', +}; +"@ +Set-Content -Path "web/firebase-config.js" -Value $js -NoNewline +Write-Host "Wrote web/firebase-config.js" +Write-Host "Done. Set FIREBASE_VAPID_KEY in .env (Console -> Cloud Messaging -> Web Push key pair)." diff --git a/tool/sync_firebase_web_config.dart b/tool/sync_firebase_web_config.dart new file mode 100644 index 0000000..5a0d103 --- /dev/null +++ b/tool/sync_firebase_web_config.dart @@ -0,0 +1,62 @@ +import 'dart:io'; + +Future main() async { + final envFile = File('.env'); + if (!await envFile.exists()) { + stderr.writeln( + '.env not found. Copy .env.example and set FIREBASE_WEB_API_KEY, ' + 'FIREBASE_WEB_APP_ID, FIREBASE_PROJECT_ID, and FIREBASE_MESSAGING_SENDER_ID.', + ); + exitCode = 1; + return; + } + + final values = {}; + for (final line in await envFile.readAsLines()) { + final trimmed = line.trim(); + if (trimmed.isEmpty || trimmed.startsWith('#')) continue; + final idx = trimmed.indexOf('='); + if (idx <= 0) continue; + values[trimmed.substring(0, idx).trim()] = trimmed.substring(idx + 1).trim(); + } + + String pick(String primary, String fallback) { + final v = values[primary]; + if (v != null && v.isNotEmpty) return v; + return values[fallback] ?? ''; + } + + final apiKey = pick('FIREBASE_WEB_API_KEY', 'FIREBASE_API_KEY'); + final appId = pick('FIREBASE_WEB_APP_ID', 'FIREBASE_APP_ID'); + final projectId = pick('FIREBASE_PROJECT_ID', 'FIREBASE_PROJECT_ID'); + final senderId = + pick('FIREBASE_MESSAGING_SENDER_ID', 'FIREBASE_MESSAGING_SENDER_ID'); + + if ([apiKey, appId, projectId, senderId].any((v) => v.isEmpty)) { + stderr.writeln( + 'Missing web Firebase keys in .env (FIREBASE_WEB_* or FIREBASE_*).', + ); + exitCode = 1; + return; + } + + final authDomain = values['FIREBASE_AUTH_DOMAIN']?.isNotEmpty == true + ? values['FIREBASE_AUTH_DOMAIN']! + : '$projectId.firebaseapp.com'; + final storageBucket = values['FIREBASE_STORAGE_BUCKET']?.isNotEmpty == true + ? values['FIREBASE_STORAGE_BUCKET']! + : '$projectId.firebasestorage.app'; + + final out = File('web/firebase-config.js'); + await out.writeAsString(''' +self.FIREBASE_WEB_CONFIG = { + apiKey: '$apiKey', + authDomain: '$authDomain', + projectId: '$projectId', + storageBucket: '$storageBucket', + messagingSenderId: '$senderId', + appId: '$appId', +}; +'''); + stdout.writeln('Wrote ${out.path}'); +} diff --git a/vercel.json b/vercel.json index d6cdbdf..498fe5b 100644 --- a/vercel.json +++ b/vercel.json @@ -1,6 +1,7 @@ { "outputDirectory": "build/web", "buildCommand": "", + "installCommand": "", "framework": null, "headers": [ { diff --git a/web/firebase-config.js b/web/firebase-config.js new file mode 100644 index 0000000..5bc7d4e --- /dev/null +++ b/web/firebase-config.js @@ -0,0 +1,8 @@ +self.FIREBASE_WEB_CONFIG = { + apiKey: 'AIzaSyDG5_rufMTdwV9X2wc7M5YNyEkWwXN8tGM', + authDomain: 'petfolio-v1.firebaseapp.com', + projectId: 'petfolio-v1', + storageBucket: 'petfolio-v1.firebasestorage.app', + messagingSenderId: '86798095066', + appId: '1:86798095066:web:61021d3c9119434a68cbe3', +}; diff --git a/web/firebase-config.js.example b/web/firebase-config.js.example new file mode 100644 index 0000000..65d3eeb --- /dev/null +++ b/web/firebase-config.js.example @@ -0,0 +1,8 @@ +self.FIREBASE_WEB_CONFIG = { + apiKey: 'YOUR_FIREBASE_API_KEY', + authDomain: 'YOUR_PROJECT.firebaseapp.com', + projectId: 'YOUR_PROJECT_ID', + storageBucket: 'YOUR_PROJECT.appspot.com', + messagingSenderId: 'YOUR_SENDER_ID', + appId: 'YOUR_WEB_APP_ID', +}; diff --git a/web/firebase-messaging-sw.js b/web/firebase-messaging-sw.js new file mode 100644 index 0000000..f190477 --- /dev/null +++ b/web/firebase-messaging-sw.js @@ -0,0 +1,28 @@ +importScripts('/firebase-config.js'); +importScripts('https://www.gstatic.com/firebasejs/11.6.0/firebase-app-compat.js'); +importScripts('https://www.gstatic.com/firebasejs/11.6.0/firebase-messaging-compat.js'); + +firebase.initializeApp(self.FIREBASE_WEB_CONFIG); +const messaging = firebase.messaging(); + +messaging.onBackgroundMessage(function (payload) { + const title = payload.notification?.title || payload.data?.title || 'PetFolio'; + const isChat = payload.data?.type === 'chat_message'; + const options = { + body: payload.notification?.body || payload.data?.body || '', + data: payload.data || {}, + icon: '/icons/Icon-192.png', + silent: false, + tag: isChat ? 'petfolio-chat' : 'petfolio-push', + }; + return self.registration.showNotification(title, options); +}); + +self.addEventListener('notificationclick', function (event) { + event.notification.close(); + const route = event.notification.data?.route; + const target = route && route.startsWith('/') + ? `${self.location.origin}/#${route}` + : self.location.origin; + event.waitUntil(clients.openWindow(target)); +}); diff --git a/web/index.html b/web/index.html index c99bb71..4adf3bf 100644 --- a/web/index.html +++ b/web/index.html @@ -6,40 +6,37 @@ - - - - - - + + + + PetFolio - - - -