Compare commits
10 Commits
76d74aa7b5
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 816e037a9a | |||
| 91bdbd5289 | |||
| be20eee16b | |||
| d04f6a8a69 | |||
| 48a1f8eb65 | |||
| 22743c81e1 | |||
| 12cf9f80e9 | |||
| 7e218decae | |||
| f59fa0e27f | |||
| eeb4ea38fc |
65
.github/workflows/README.md
vendored
Normal file
65
.github/workflows/README.md
vendored
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# CI Workflows
|
||||||
|
|
||||||
|
## `build.yml` — parameterized build
|
||||||
|
|
||||||
|
Manually-triggered, **build-only** pipeline (no push, no deploy). Produces downloadable artifacts.
|
||||||
|
|
||||||
|
### How to run
|
||||||
|
1. GitHub → **Actions** tab → **Build** (left sidebar) → **Run workflow**.
|
||||||
|
2. Pick:
|
||||||
|
- **environment** — `staging` or `prod`
|
||||||
|
- **target** — `all`, `customer`, `mitra`, `backend`, or `control_center`
|
||||||
|
- **platform** — `android`, `ios`, or `both` (only affects the customer/mitra apps; ignored for backend/control_center)
|
||||||
|
3. **Run workflow**. Open the run; artifacts appear at the bottom of the summary page when it finishes.
|
||||||
|
|
||||||
|
(You can also trigger it from the CLI: `gh workflow run build.yml -f environment=staging -f target=all -f platform=android`.)
|
||||||
|
|
||||||
|
### Runner split (Linux vs macOS)
|
||||||
|
iOS can only be built on macOS, so the workflow routes jobs accordingly:
|
||||||
|
|
||||||
|
| Job | Runner |
|
||||||
|
|---|---|
|
||||||
|
| `customer-ios`, `mitra-ios` | `macos-latest` |
|
||||||
|
| `customer-android`, `mitra-android`, `backend`, `control_center` | `ubuntu-latest` |
|
||||||
|
|
||||||
|
> ⚠️ **macOS runner minutes bill at ~10× Linux.** That's why `platform` defaults to `android`. Choose `ios`/`both` deliberately.
|
||||||
|
|
||||||
|
### What each target produces
|
||||||
|
|
||||||
|
| Target | Platform | Tool | Artifact | Notes |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `customer` | android | Flutter | `customer-<env>-android-apk` → `app-<env>-release.apk` | Debug-signed (no keystore in repo). Fine for internal/Firebase App Distribution. |
|
||||||
|
| `customer` | ios | Flutter | `customer-<env>-ios-app` (`Runner.app`) | **Unsigned** (`--no-codesign`). See iOS prerequisites below. |
|
||||||
|
| `mitra` | android | Flutter | `mitra-<env>-android-apk` | Same as customer-android. |
|
||||||
|
| `mitra` | ios | Flutter | `mitra-<env>-ios-app` | Same caveats as customer-ios. |
|
||||||
|
| `control_center` | — | Vite | `control-center-<env>-dist` (the `dist/` folder) | `VITE_API_BASE_URL` baked in at build time — see below. |
|
||||||
|
| `backend` | — | Docker | `backend-<env>-image` (`*.tar.gz`) | Env-**agnostic** image (config is runtime env vars). Load with `docker load`. |
|
||||||
|
|
||||||
|
`all` runs every selected job in parallel; each is independent, so one failing doesn't block the others.
|
||||||
|
|
||||||
|
### ⚠️ iOS prerequisites (not satisfied yet)
|
||||||
|
The iOS jobs are wired but will **fail until two things are done**:
|
||||||
|
1. **Per-flavor Xcode schemes** (`staging`, `prod`) must exist in `ios/Runner.xcodeproj`. Today there's only the default `Runner` scheme, so `flutter build ios --flavor staging` errors with *"The Xcode project does not define custom schemes"*. This mirrors the Android flavor setup but on the iOS side (schemes + build configurations + per-config bundle IDs).
|
||||||
|
2. **Code signing** for a distributable `.ipa`: Apple Developer certificate + provisioning profiles stored as GitHub secrets. The current jobs use `--no-codesign`, which only validates that the app **compiles** and produces an unsigned `Runner.app` (not installable on devices).
|
||||||
|
|
||||||
|
Until then, `platform=ios`/`both` is useful only once the schemes land. `platform=android` works today.
|
||||||
|
|
||||||
|
### Environment specifics
|
||||||
|
- **Apps (customer/mitra):** `environment` selects the Flutter flavor + entrypoint + `env/<env>.json` (`--flavor`, `-t lib/main_<env>.dart`, `--dart-define-from-file`). Reminder: `env/staging.json` still has a **placeholder API URL** until the staging backend is deployed.
|
||||||
|
- **control_center:** `VITE_API_BASE_URL` is compiled in. Defaults: prod → `https://internal.halobestie.com`, staging → `https://staging-internal.halobestie.com`. Override without editing the workflow by setting repo **Variables** (Settings → Secrets and variables → Actions → Variables): `CC_API_BASE_URL_PROD`, `CC_API_BASE_URL_STAGING`.
|
||||||
|
- **backend:** the image is identical across environments; env/secrets are supplied at `docker run` time (see [backend/DEPLOY.md](../../backend/DEPLOY.md)). The `<env>` in the artifact name is just a label.
|
||||||
|
|
||||||
|
### Deploying a build artifact (manual, since we don't push yet)
|
||||||
|
- **Backend:** download `backend-<env>-image`, copy to the host, then:
|
||||||
|
```bash
|
||||||
|
gunzip -c halobestie-backend-<env>.tar.gz | docker load
|
||||||
|
# then run per backend/DEPLOY.md
|
||||||
|
```
|
||||||
|
- **control_center:** download `control-center-<env>-dist`, serve the `dist/` behind Nginx (internal-only).
|
||||||
|
- **Apps:** download the APK and install / upload to Firebase App Distribution.
|
||||||
|
|
||||||
|
### Pinned versions
|
||||||
|
Flutter `3.41.9`, JDK `17`, Node `20` (see `env:` block in [build.yml](build.yml)). Bump them there when the team upgrades.
|
||||||
|
|
||||||
|
### Not included yet (by design)
|
||||||
|
Pushing images to a registry and deploying are intentionally out of scope for this first iteration. When you're ready, the natural next step is a `release.yml` that pushes to GHCR and (optionally, via a self-hosted runner or SSH) deploys to the VPS.
|
||||||
258
.github/workflows/build.yml
vendored
Normal file
258
.github/workflows/build.yml
vendored
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
name: Build
|
||||||
|
|
||||||
|
# Manually triggered, parameterized build pipeline (build-only — no push/deploy).
|
||||||
|
#
|
||||||
|
# Trigger: GitHub → Actions tab → "Build" → "Run workflow" → pick:
|
||||||
|
# environment: staging | prod (which flavor/config to build)
|
||||||
|
# target: all | customer | mitra | backend | control_center
|
||||||
|
# platform: android | ios | both (only affects the customer/mitra apps)
|
||||||
|
#
|
||||||
|
# Runner split: iOS app jobs run on macOS runners (iOS can't build on Linux);
|
||||||
|
# everything else (Android, backend, control_center) runs on Linux.
|
||||||
|
#
|
||||||
|
# Outputs land as downloadable artifacts on the run summary page.
|
||||||
|
# See .github/workflows/README.md for details.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
environment:
|
||||||
|
description: "Build environment"
|
||||||
|
type: choice
|
||||||
|
required: true
|
||||||
|
default: staging
|
||||||
|
options: [staging, prod]
|
||||||
|
target:
|
||||||
|
description: "What to build"
|
||||||
|
type: choice
|
||||||
|
required: true
|
||||||
|
default: all
|
||||||
|
options: [all, customer, mitra, backend, control_center]
|
||||||
|
platform:
|
||||||
|
description: "Mobile platform (apps only; macOS runners cost ~10x)"
|
||||||
|
type: choice
|
||||||
|
required: true
|
||||||
|
default: android
|
||||||
|
options: [android, ios, both]
|
||||||
|
|
||||||
|
run-name: "Build ${{ inputs.target }} (${{ inputs.environment }}, ${{ inputs.platform }})"
|
||||||
|
|
||||||
|
# Cancel an in-flight run of the same target+env+platform when a new one starts.
|
||||||
|
concurrency:
|
||||||
|
group: build-${{ inputs.target }}-${{ inputs.environment }}-${{ inputs.platform }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
FLUTTER_VERSION: "3.41.9" # keep in sync with the team's local Flutter
|
||||||
|
JAVA_VERSION: "17" # AGP 8 requires JDK 17
|
||||||
|
NODE_VERSION: "20" # matches backend engines + control_center
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Expand the `target` input into a JSON list the build jobs gate on.
|
||||||
|
# `all` → every component; otherwise just the one chosen.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
prepare:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
targets: ${{ steps.expand.outputs.targets }}
|
||||||
|
steps:
|
||||||
|
- id: expand
|
||||||
|
run: |
|
||||||
|
if [ "${{ inputs.target }}" = "all" ]; then
|
||||||
|
echo 'targets=["customer","mitra","backend","control_center"]' >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo 'targets=["${{ inputs.target }}"]' >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
- run: echo "Building → ${{ steps.expand.outputs.targets }} (env=${{ inputs.environment }}, platform=${{ inputs.platform }})"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# customer app — ANDROID (Linux runner). Debug-signed (no keystore in repo).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
customer-android:
|
||||||
|
needs: prepare
|
||||||
|
if: contains(fromJSON(needs.prepare.outputs.targets), 'customer') && (inputs.platform == 'android' || inputs.platform == 'both')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: client_app
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: ${{ env.JAVA_VERSION }}
|
||||||
|
- uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
|
channel: stable
|
||||||
|
cache: true
|
||||||
|
- run: flutter pub get
|
||||||
|
- name: Build APK
|
||||||
|
run: |
|
||||||
|
flutter build apk \
|
||||||
|
--flavor ${{ inputs.environment }} \
|
||||||
|
-t lib/main_${{ inputs.environment }}.dart \
|
||||||
|
--dart-define-from-file=env/${{ inputs.environment }}.json
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: customer-${{ inputs.environment }}-android-apk
|
||||||
|
path: client_app/build/app/outputs/flutter-apk/app-${{ inputs.environment }}-release.apk
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# customer app — iOS (macOS runner). Unsigned build (--no-codesign).
|
||||||
|
# PREREQ: per-flavor Xcode schemes must exist (staging/prod). Until then this
|
||||||
|
# fails with "The Xcode project does not define custom schemes". Distributable
|
||||||
|
# IPAs additionally need signing certs/profiles as secrets — not set up yet.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
customer-ios:
|
||||||
|
needs: prepare
|
||||||
|
if: contains(fromJSON(needs.prepare.outputs.targets), 'customer') && (inputs.platform == 'ios' || inputs.platform == 'both')
|
||||||
|
runs-on: macos-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: client_app
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
|
channel: stable
|
||||||
|
cache: true
|
||||||
|
- run: flutter pub get
|
||||||
|
- name: Build iOS (unsigned)
|
||||||
|
run: |
|
||||||
|
flutter build ios --no-codesign \
|
||||||
|
--flavor ${{ inputs.environment }} \
|
||||||
|
-t lib/main_${{ inputs.environment }}.dart \
|
||||||
|
--dart-define-from-file=env/${{ inputs.environment }}.json
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: customer-${{ inputs.environment }}-ios-app
|
||||||
|
path: client_app/build/ios/iphoneos/Runner.app
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# mitra app — ANDROID (Linux runner).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
mitra-android:
|
||||||
|
needs: prepare
|
||||||
|
if: contains(fromJSON(needs.prepare.outputs.targets), 'mitra') && (inputs.platform == 'android' || inputs.platform == 'both')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: mitra_app
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: ${{ env.JAVA_VERSION }}
|
||||||
|
- uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
|
channel: stable
|
||||||
|
cache: true
|
||||||
|
- run: flutter pub get
|
||||||
|
- name: Build APK
|
||||||
|
run: |
|
||||||
|
flutter build apk \
|
||||||
|
--flavor ${{ inputs.environment }} \
|
||||||
|
-t lib/main_${{ inputs.environment }}.dart \
|
||||||
|
--dart-define-from-file=env/${{ inputs.environment }}.json
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: mitra-${{ inputs.environment }}-android-apk
|
||||||
|
path: mitra_app/build/app/outputs/flutter-apk/app-${{ inputs.environment }}-release.apk
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# mitra app — iOS (macOS runner). Same PREREQ caveats as customer-ios.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
mitra-ios:
|
||||||
|
needs: prepare
|
||||||
|
if: contains(fromJSON(needs.prepare.outputs.targets), 'mitra') && (inputs.platform == 'ios' || inputs.platform == 'both')
|
||||||
|
runs-on: macos-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: mitra_app
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
|
channel: stable
|
||||||
|
cache: true
|
||||||
|
- run: flutter pub get
|
||||||
|
- name: Build iOS (unsigned)
|
||||||
|
run: |
|
||||||
|
flutter build ios --no-codesign \
|
||||||
|
--flavor ${{ inputs.environment }} \
|
||||||
|
-t lib/main_${{ inputs.environment }}.dart \
|
||||||
|
--dart-define-from-file=env/${{ inputs.environment }}.json
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: mitra-${{ inputs.environment }}-ios-app
|
||||||
|
path: mitra_app/build/ios/iphoneos/Runner.app
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# control_center — Vite SPA static build (Linux). VITE_API_BASE_URL is baked
|
||||||
|
# in at build time. Override defaults via repo Variables CC_API_BASE_URL_PROD
|
||||||
|
# / CC_API_BASE_URL_STAGING (Settings → Secrets and variables → Actions).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
control_center:
|
||||||
|
needs: prepare
|
||||||
|
if: contains(fromJSON(needs.prepare.outputs.targets), 'control_center')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: control_center
|
||||||
|
env:
|
||||||
|
VITE_API_BASE_URL: >-
|
||||||
|
${{ inputs.environment == 'prod'
|
||||||
|
&& (vars.CC_API_BASE_URL_PROD || 'https://internal.halobestie.com')
|
||||||
|
|| (vars.CC_API_BASE_URL_STAGING || 'https://staging-internal.halobestie.com') }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: control_center/package-lock.json
|
||||||
|
- run: npm ci
|
||||||
|
- name: Build (VITE_API_BASE_URL=${{ env.VITE_API_BASE_URL }})
|
||||||
|
run: npm run build
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: control-center-${{ inputs.environment }}-dist
|
||||||
|
path: control_center/dist
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# backend — Docker image (Linux). Env-AGNOSTIC (config is runtime env vars),
|
||||||
|
# built once and exported as a loadable tarball. Deploy without a registry:
|
||||||
|
# gunzip -c halobestie-backend-*.tar.gz | docker load
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
backend:
|
||||||
|
needs: prepare
|
||||||
|
if: contains(fromJSON(needs.prepare.outputs.targets), 'backend')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Build image
|
||||||
|
run: docker build -t halobestie-backend:${{ inputs.environment }} ./backend
|
||||||
|
- name: Export image as tarball
|
||||||
|
run: docker save halobestie-backend:${{ inputs.environment }} | gzip > halobestie-backend-${{ inputs.environment }}.tar.gz
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: backend-${{ inputs.environment }}-image
|
||||||
|
path: halobestie-backend-${{ inputs.environment }}.tar.gz
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 14
|
||||||
22
backend/.dockerignore
Normal file
22
backend/.dockerignore
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Deps are reinstalled inside the image via `npm ci`
|
||||||
|
node_modules
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# Secrets / local env — mounted at runtime, never baked in
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# VCS + tooling
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Tests + coverage (not needed in the runtime image)
|
||||||
|
coverage
|
||||||
|
docker-compose*.yml
|
||||||
|
**/*.test.js
|
||||||
|
|
||||||
|
# Docs
|
||||||
|
*.md
|
||||||
@@ -10,7 +10,7 @@ Fastify.js REST API serving both mobile apps and the internal control center.
|
|||||||
- **Database:** PostgreSQL via GCP Cloud SQL
|
- **Database:** PostgreSQL via GCP Cloud SQL
|
||||||
- **Auth:** Self-managed JWT (HS256 access, 1h) + opaque refresh token (30d, rotated, bcrypt-hashed in `auth_sessions`). Firebase Auth removed in Phase 3.4 (commit `f860ab6`). `firebase-admin` is kept but only for FCM messaging.
|
- **Auth:** Self-managed JWT (HS256 access, 1h) + opaque refresh token (30d, rotated, bcrypt-hashed in `auth_sessions`). Firebase Auth removed in Phase 3.4 (commit `f860ab6`). `firebase-admin` is kept but only for FCM messaging.
|
||||||
- **Payment:** Xendit
|
- **Payment:** Xendit
|
||||||
- **Infra:** GCP Cloud Run
|
- **Infra:** Self-hosted Docker (VPS / Kubernetes / Docker Engine) — **not** Cloud Run. Multi-stage [Dockerfile](Dockerfile); deploy + log runbook in [DEPLOY.md](DEPLOY.md). DB is PostgreSQL (managed or self-hosted).
|
||||||
|
|
||||||
## Two Listeners
|
## Two Listeners
|
||||||
|
|
||||||
|
|||||||
209
backend/DEPLOY.md
Normal file
209
backend/DEPLOY.md
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
# Backend — Docker Deployment Guide
|
||||||
|
|
||||||
|
Operational guide for building, deploying, and observing the Halo Bestie backend as a **Docker container** on self-hosted infra (VPS, Docker Engine, or Kubernetes). **Not** Cloud Run / serverless.
|
||||||
|
|
||||||
|
> Architecture / env-var reference: [../requirement/deployment.md](../requirement/deployment.md) · [.env.example](.env.example)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What gets deployed
|
||||||
|
|
||||||
|
A single image (multi-stage [Dockerfile](Dockerfile)) running `node src/server.js`, which starts **two listeners** ([src/server.js](src/server.js)):
|
||||||
|
|
||||||
|
| Listener | Bind | Port | Exposed? |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Public API (`client_app` + `mitra_app`) | `0.0.0.0` | `PUBLIC_PORT` (default **3000**) | **Yes** — publish this |
|
||||||
|
| Internal API (control center) | `INTERNAL_HOST` (default `127.0.0.1`) | `INTERNAL_PORT` (default 3001) | **No** — loopback only, never publish |
|
||||||
|
|
||||||
|
Runtime image: Node 20 (bookworm-slim), prod-only deps, runs as non-root `node`, native `bcrypt` precompiled in the build stage.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Install Docker (one-time, on the host)
|
||||||
|
|
||||||
|
### Ubuntu / Debian VPS
|
||||||
|
```bash
|
||||||
|
# Remove any old packages, then install Docker Engine from the official repo
|
||||||
|
curl -fsSL https://get.docker.com | sh
|
||||||
|
|
||||||
|
# Run docker as your user without sudo (log out/in afterward)
|
||||||
|
sudo usermod -aG docker "$USER"
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
docker version
|
||||||
|
docker compose version # Compose v2 ships as a plugin with modern Docker
|
||||||
|
```
|
||||||
|
|
||||||
|
### Kubernetes
|
||||||
|
No Docker Engine needed on nodes — just push the image to a registry your cluster can pull from (see §4) and apply your manifests (§7).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Configure environment
|
||||||
|
|
||||||
|
Create `backend/.env.staging` (or `.env.production`) from the template — **never commit it**:
|
||||||
|
```bash
|
||||||
|
cp .env.example .env.staging
|
||||||
|
```
|
||||||
|
Fill in at minimum (full list in [.env.example](.env.example)):
|
||||||
|
|
||||||
|
| Var | Notes |
|
||||||
|
|---|---|
|
||||||
|
| `PUBLIC_PORT` | `3000` (keep default unless your proxy expects otherwise) |
|
||||||
|
| `INTERNAL_HOST` / `INTERNAL_PORT` | leave default `127.0.0.1:3001` — keeps control center private |
|
||||||
|
| `DATABASE_URL` | Postgres connection string |
|
||||||
|
| `VALKEY_URL` | `redis://<host>:6379` |
|
||||||
|
| `SERVER_TZ` | `UTC` |
|
||||||
|
| `AUTH_JWT_SECRET` | **fresh per environment** — never reuse prod's |
|
||||||
|
| `FIREBASE_SERVICE_ACCOUNT_PATH` | path to the **mounted** SA JSON, e.g. `/secrets/firebase-sa.json`. Must be from the env's Firebase project (staging = `my-bestie-876ec`) |
|
||||||
|
| `XENDIT_ENABLED` | `false` until test keys + webhook are wired |
|
||||||
|
| `CC_ORIGIN`, `ADMIN_EMAIL`, `ADMIN_PASSWORD` | control-center access |
|
||||||
|
|
||||||
|
> Secrets (`.env`, the Firebase SA JSON) are provided at **runtime** via `--env-file` / volume mounts / k8s Secrets. They are **not** baked into the image (`.dockerignore` excludes `.env*`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Build & push the image
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From the repo root
|
||||||
|
docker build -t <registry>/halobestie-backend:staging ./backend
|
||||||
|
|
||||||
|
# Push to your registry (Docker Hub, GHCR, GCP Artifact Registry, self-hosted, …)
|
||||||
|
docker push <registry>/halobestie-backend:staging
|
||||||
|
```
|
||||||
|
For a purely single-host setup you can skip the registry and build directly on the host.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Run database migrations (one-off)
|
||||||
|
|
||||||
|
Run **before** (re)starting the service. Never auto-migrate on container boot — concurrent replicas would race.
|
||||||
|
```bash
|
||||||
|
# Migrate (every deploy that includes new migrations)
|
||||||
|
docker run --rm --env-file backend/.env.staging \
|
||||||
|
<registry>/halobestie-backend:staging node src/db/migrate.js
|
||||||
|
|
||||||
|
# Seed (first deploy only)
|
||||||
|
docker run --rm --env-file backend/.env.staging \
|
||||||
|
<registry>/halobestie-backend:staging node src/db/seed.js
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Deploy — plain Docker Engine
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d --name halobestie-staging \
|
||||||
|
--env-file backend/.env.staging \
|
||||||
|
-p 3000:3000 \
|
||||||
|
-v /opt/halobestie/secrets/firebase-sa.json:/secrets/firebase-sa.json:ro \
|
||||||
|
--restart unless-stopped \
|
||||||
|
--log-driver json-file --log-opt max-size=10m --log-opt max-file=5 \
|
||||||
|
<registry>/halobestie-backend:staging
|
||||||
|
```
|
||||||
|
- Publish **only** `3000`. Do **not** map `3001`.
|
||||||
|
- `--log-opt` enables log rotation — see §8.
|
||||||
|
- Put a TLS-terminating reverse proxy (Nginx / Traefik / Caddy) in front for `https://staging-api.halobestie.com`. WebSocket upgrade must be proxied (the apps use `/api/shared/ws`).
|
||||||
|
|
||||||
|
### Or with Docker Compose
|
||||||
|
A ready-to-use [docker-compose.staging.yml](docker-compose.staging.yml) is included (backend only — Postgres/Valkey are expected via `DATABASE_URL`/`VALKEY_URL`). It publishes only `3000`, mounts the Firebase SA + log volume, and sets json-file rotation. Point it at your image via the `BACKEND_IMAGE` env var (or uncomment `build: .` to build on the host):
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
cp .env.example .env.staging # then fill it in
|
||||||
|
BACKEND_IMAGE=<registry>/halobestie-backend:staging \
|
||||||
|
docker compose -f docker-compose.staging.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Deploy — Kubernetes (sketch)
|
||||||
|
|
||||||
|
- **Deployment** with the image, `envFrom` a Secret/ConfigMap, the Firebase SA JSON mounted from a Secret volume at `/secrets/firebase-sa.json`.
|
||||||
|
- **Service** exposing only container port `3000`. Liveness/readiness probes: `tcpSocket: { port: 3000 }` (no HTTP health route — TCP probe matches the Dockerfile HEALTHCHECK).
|
||||||
|
- Migrations: a one-off **Job** (`command: ["node","src/db/migrate.js"]`) run before rolling out, not an initContainer on every pod.
|
||||||
|
- Logs: pods write to stdout (§8) — your cluster's node agent (Fluent Bit / Loki / Cloud Logging) collects them automatically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Logs — where they go and how to map them
|
||||||
|
|
||||||
|
### 8a. Application logs → **stdout/stderr** (the Docker-native way)
|
||||||
|
The backend uses Fastify's pino logger (`logger: true` in [src/app.public.js](src/app.public.js) / [src/app.internal.js](src/app.internal.js)), emitting **structured JSON to stdout**, plus a few `console.log` lifecycle lines. It does **not** write its own app-log files. So:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Tail live
|
||||||
|
docker logs -f halobestie-staging
|
||||||
|
|
||||||
|
# Last 200 lines
|
||||||
|
docker logs --tail 200 halobestie-staging
|
||||||
|
|
||||||
|
# Pretty-print the JSON (pino output is one JSON object per line)
|
||||||
|
docker logs -f halobestie-staging | jq .
|
||||||
|
|
||||||
|
# Compose equivalent
|
||||||
|
docker compose -f docker-compose.staging.yml logs -f backend
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rotation (important — default json-file logs grow unbounded):**
|
||||||
|
set it per-container as in §6 (`--log-opt max-size=10m --log-opt max-file=5`), or globally in `/etc/docker/daemon.json`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"log-driver": "json-file",
|
||||||
|
"log-opts": { "max-size": "10m", "max-file": "5" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
then `sudo systemctl restart docker`.
|
||||||
|
|
||||||
|
**Ship logs off-host (optional):** point Docker at a log driver instead of/alongside json-file — e.g. `--log-driver=loki`, `--log-driver=fluentd`, `--log-driver=syslog`, or `--log-driver=gcplogs`. On k8s, stdout is collected by the node logging agent; no per-container config needed.
|
||||||
|
|
||||||
|
**Persist raw stdout to a host file (simple VPS option):**
|
||||||
|
```bash
|
||||||
|
docker run -d --name halobestie-staging ... \
|
||||||
|
<image> > /var/log/halobestie/backend.log 2>&1
|
||||||
|
# better: use the json-file driver (above) and read /var/lib/docker/containers/<id>/<id>-json.log,
|
||||||
|
# or redirect via your reverse proxy / a sidecar. Prefer a real log driver for rotation.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8b. Xendit webhook fallback JSONL → **needs a volume** (only if enabled)
|
||||||
|
The one component that writes a **file** is the optional webhook fallback sink ([src/services/webhook-log.service.js](src/services/webhook-log.service.js)), **off by default**. When `XENDIT_WEBHOOK_FALLBACK_ENABLED=true`, it writes rolling JSONL to `XENDIT_WEBHOOK_FALLBACK_DIR` (default `./logs` → `/app/logs` in the container). To keep those across restarts, **mount a volume**:
|
||||||
|
```bash
|
||||||
|
docker run -d ... \
|
||||||
|
-e XENDIT_WEBHOOK_FALLBACK_ENABLED=true \
|
||||||
|
-e XENDIT_WEBHOOK_FALLBACK_DIR=/app/logs \
|
||||||
|
-v /opt/halobestie/logs:/app/logs \
|
||||||
|
<image>
|
||||||
|
```
|
||||||
|
(The Compose example in §6 already declares the `backend-logs` volume for this.) If the fallback stays disabled, you don't need this volume — everything is on stdout.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Health, upgrade, rollback
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Health — the image has a built-in TCP HEALTHCHECK; check it:
|
||||||
|
docker inspect --format '{{.State.Health.Status}}' halobestie-staging
|
||||||
|
|
||||||
|
# Upgrade to a new image
|
||||||
|
docker pull <registry>/halobestie-backend:staging
|
||||||
|
docker run ... node src/db/migrate.js # if new migrations
|
||||||
|
docker stop halobestie-staging && docker rm halobestie-staging
|
||||||
|
docker run -d --name halobestie-staging ... # re-run with the new image
|
||||||
|
|
||||||
|
# Rollback = re-run the previous tag/digest. Graceful shutdown is handled:
|
||||||
|
# server.js traps SIGTERM and drains the listeners before exit.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Quick reference
|
||||||
|
|
||||||
|
| Task | Command |
|
||||||
|
|---|---|
|
||||||
|
| Build | `docker build -t <img> ./backend` |
|
||||||
|
| Migrate | `docker run --rm --env-file .env.staging <img> node src/db/migrate.js` |
|
||||||
|
| Run | `docker run -d --name halobestie-staging --env-file .env.staging -p 3000:3000 --restart unless-stopped <img>` |
|
||||||
|
| Logs (live) | `docker logs -f halobestie-staging` |
|
||||||
|
| Logs (pretty) | `docker logs -f halobestie-staging \| jq .` |
|
||||||
|
| Health | `docker inspect --format '{{.State.Health.Status}}' halobestie-staging` |
|
||||||
|
| Shell in | `docker exec -it halobestie-staging sh` |
|
||||||
44
backend/Dockerfile
Normal file
44
backend/Dockerfile
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Stage 1 — builder: install production deps, compiling native addons (bcrypt)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
FROM node:20-bookworm-slim AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Toolchain required to compile native modules (bcrypt) when no prebuilt
|
||||||
|
# binary matches the platform. Lives only in this stage.
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
python3 make g++ \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install against the lockfile for reproducible builds. Copy manifests first
|
||||||
|
# so this layer caches until deps actually change.
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Stage 2 — runtime: slim image with only prod node_modules + app source
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
FROM node:20-bookworm-slim AS runtime
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Compiled node_modules (same base image → ABI-compatible bcrypt .node binary).
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
COPY package.json ./
|
||||||
|
COPY src ./src
|
||||||
|
|
||||||
|
# Drop privileges — node:* images ship a non-root `node` user.
|
||||||
|
USER node
|
||||||
|
|
||||||
|
# Public listener only. INTERNAL_PORT (3001) binds to 127.0.0.1 inside the
|
||||||
|
# container by default and is intentionally NOT published.
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# No HTTP health route exists — probe the TCP port directly so the check is
|
||||||
|
# route-agnostic. Orchestrators (k8s) can override with their own probes.
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
|
||||||
|
CMD node -e "require('net').connect({port: process.env.PUBLIC_PORT||3000, host:'127.0.0.1'}).on('connect',()=>process.exit(0)).on('error',()=>process.exit(1))"
|
||||||
|
|
||||||
|
CMD ["node", "src/server.js"]
|
||||||
39
backend/docker-compose.staging.yml
Normal file
39
backend/docker-compose.staging.yml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Staging deploy for the Halo Bestie backend (self-hosted Docker).
|
||||||
|
# Usage:
|
||||||
|
# cd backend
|
||||||
|
# docker compose -f docker-compose.staging.yml up -d
|
||||||
|
#
|
||||||
|
# Prereqs: a populated .env.staging (cp .env.example .env.staging) and the
|
||||||
|
# Firebase service-account JSON at the mounted host path below. See DEPLOY.md.
|
||||||
|
#
|
||||||
|
# This runs ONLY the backend. Postgres + Valkey are expected to be reachable
|
||||||
|
# via DATABASE_URL / VALKEY_URL in .env.staging (managed/self-hosted elsewhere).
|
||||||
|
# TLS termination + the public hostname are handled by a reverse proxy in front.
|
||||||
|
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
image: ${BACKEND_IMAGE:-halobestie-backend:staging}
|
||||||
|
# To build on the host instead of pulling a pushed image, comment out
|
||||||
|
# `image:` above and uncomment:
|
||||||
|
# build: .
|
||||||
|
container_name: halobestie-staging
|
||||||
|
env_file: .env.staging
|
||||||
|
ports:
|
||||||
|
- "3000:3000" # public listener only — never publish 3001
|
||||||
|
volumes:
|
||||||
|
# Firebase service-account JSON (must match the env's Firebase project,
|
||||||
|
# staging = my-bestie-876ec). FIREBASE_SERVICE_ACCOUNT_PATH in .env.staging
|
||||||
|
# must equal the in-container path on the right.
|
||||||
|
- /opt/halobestie/secrets/firebase-sa.json:/secrets/firebase-sa.json:ro
|
||||||
|
# Optional: only needed if XENDIT_WEBHOOK_FALLBACK_ENABLED=true (writes
|
||||||
|
# rolling JSONL to /app/logs). App logs themselves go to stdout — see DEPLOY.md §8.
|
||||||
|
- backend-logs:/app/logs
|
||||||
|
restart: unless-stopped
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "5"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
backend-logs:
|
||||||
@@ -4,6 +4,9 @@
|
|||||||
"description": "Halo Bestie backend API",
|
"description": "Halo Bestie backend API",
|
||||||
"main": "src/server.js",
|
"main": "src/server.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node --watch src/server.js",
|
"dev": "node --watch src/server.js",
|
||||||
"start": "node src/server.js",
|
"start": "node src/server.js",
|
||||||
|
|||||||
85
backend/scripts/setup-test-mitra-otp.mjs
Normal file
85
backend/scripts/setup-test-mitra-otp.mjs
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
// Dev helper: provision a STATIC-OTP mitra login for local/QA testing.
|
||||||
|
//
|
||||||
|
// Uses the existing test-OTP-bypass allowlist (app_config.test_otp_bypass),
|
||||||
|
// the same mechanism shipped for Apple-reviewer QA. It:
|
||||||
|
// 1. ensures an ACTIVE mitra exists with the test phone (mitras default to
|
||||||
|
// is_active=false, which the mitra verify route rejects with 403),
|
||||||
|
// 2. adds a phone-scoped, mitra-scoped static OTP entry (bcrypt-hashed),
|
||||||
|
// 3. flips the global bypass kill-switch on.
|
||||||
|
//
|
||||||
|
// After running, log into the mitra app with PHONE + OTP below — no Fazpass,
|
||||||
|
// no console code-reading. Re-running is idempotent.
|
||||||
|
//
|
||||||
|
// Usage (from backend/): node scripts/setup-test-mitra-otp.mjs
|
||||||
|
// Override defaults: TEST_MITRA_PHONE=+628... TEST_MITRA_OTP=123456 node scripts/setup-test-mitra-otp.mjs
|
||||||
|
|
||||||
|
import 'dotenv/config'
|
||||||
|
import { getDb } from '../src/db/client.js'
|
||||||
|
import {
|
||||||
|
getTestOtpBypass,
|
||||||
|
addTestOtpBypassEntry,
|
||||||
|
setTestOtpBypassEnabled,
|
||||||
|
} from '../src/services/config.service.js'
|
||||||
|
|
||||||
|
const PHONE = process.env.TEST_MITRA_PHONE || '+6281200000001'
|
||||||
|
const OTP = process.env.TEST_MITRA_OTP || '123456'
|
||||||
|
const LABEL = process.env.TEST_MITRA_LABEL || 'Dev static OTP (mitra)'
|
||||||
|
const DISPLAY_NAME = process.env.TEST_MITRA_NAME || 'Test Bestie'
|
||||||
|
// Far-future expiry — the allowlist requires a future expires_at per entry.
|
||||||
|
const EXPIRES_AT = '2099-01-01T00:00:00.000Z'
|
||||||
|
|
||||||
|
const sql = getDb()
|
||||||
|
|
||||||
|
async function main () {
|
||||||
|
// 1. Ensure an ACTIVE mitra with this phone (raw SQL — avoids importing
|
||||||
|
// mitra.service, which pulls in the valkey plugin and would leave an open
|
||||||
|
// handle keeping this script alive).
|
||||||
|
const [existing] = await sql`SELECT id, is_active FROM mitras WHERE phone = ${PHONE}`
|
||||||
|
if (!existing) {
|
||||||
|
const [m] = await sql`
|
||||||
|
INSERT INTO mitras (phone, display_name, is_active)
|
||||||
|
VALUES (${PHONE}, ${DISPLAY_NAME}, true)
|
||||||
|
RETURNING id
|
||||||
|
`
|
||||||
|
console.log(` created active mitra ${m.id} (${PHONE})`)
|
||||||
|
} else if (!existing.is_active) {
|
||||||
|
await sql`UPDATE mitras SET is_active = true WHERE id = ${existing.id}`
|
||||||
|
console.log(` mitra ${existing.id} existed — activated`)
|
||||||
|
} else {
|
||||||
|
console.log(` mitra ${existing.id} already exists and active`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Add the static-OTP allowlist entry (skip if one already exists for this
|
||||||
|
// phone+mitra — addTestOtpBypassEntry throws on duplicate).
|
||||||
|
const current = await getTestOtpBypass()
|
||||||
|
const exists = current.entries.some(e => e.phone === PHONE && e.user_type === 'mitra')
|
||||||
|
if (exists) {
|
||||||
|
console.log(' bypass entry already present for this phone+mitra — leaving as is')
|
||||||
|
console.log(' (to rotate the OTP: delete the entry in CC → Settings, then re-run)')
|
||||||
|
} else {
|
||||||
|
await addTestOtpBypassEntry({
|
||||||
|
phone: PHONE,
|
||||||
|
otp: OTP,
|
||||||
|
user_type: 'mitra',
|
||||||
|
label: LABEL,
|
||||||
|
expires_at: EXPIRES_AT,
|
||||||
|
})
|
||||||
|
console.log(` added bypass entry: ${PHONE} → otp ${OTP} (mitra)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Global kill-switch ON.
|
||||||
|
await setTestOtpBypassEnabled(true)
|
||||||
|
console.log(' bypass allowlist ENABLED')
|
||||||
|
|
||||||
|
console.log('\n✅ Static mitra OTP ready:')
|
||||||
|
console.log(` phone: ${PHONE}`)
|
||||||
|
console.log(` otp: ${exists ? '(unchanged — set on a previous run)' : OTP}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.then(() => sql.end({ timeout: 5 }))
|
||||||
|
.catch(async (err) => {
|
||||||
|
console.error('FAILED:', err.message)
|
||||||
|
await sql.end({ timeout: 5 })
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
184
client_app/BUILD_FLAVORS.md
Normal file
184
client_app/BUILD_FLAVORS.md
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
# Build Flavors — client_app (Android + iOS identity)
|
||||||
|
|
||||||
|
Three build flavors map each environment to its own app ID, backend URL, app
|
||||||
|
display name, and Firebase config. This lets dev / staging / prod builds sit
|
||||||
|
**side-by-side** on one device (distinct package / bundle IDs).
|
||||||
|
|
||||||
|
> Scope: **Android flavors + Dart + env files are fully wired.** On iOS, only
|
||||||
|
> the **bundle identity** is set (see the iOS section below); the iOS **Xcode
|
||||||
|
> build-configuration + scheme wiring** (per-flavor bundle suffixing,
|
||||||
|
> per-scheme `GoogleService-Info.plist`) is a separate follow-up that needs a
|
||||||
|
> Mac and is NOT done here.
|
||||||
|
|
||||||
|
## Flavor table
|
||||||
|
|
||||||
|
| Flavor | applicationId | App name | API_BASE_URL |
|
||||||
|
|----------|-------------------------|--------------------|-------------------------------------------|
|
||||||
|
| dev | `com.mybestie.dev` | HaloBestie Dev | `http://192.168.88.247:3000` |
|
||||||
|
| staging | `com.mybestie.staging` | HaloBestie Staging | `https://staging-api.halobestie.com` ⚠️\* |
|
||||||
|
| prod | `com.mybestie` | HaloBestie | `https://api.halobestie.com` |
|
||||||
|
|
||||||
|
\* staging URL is a **PLACEHOLDER** — confirm the real staging host and update
|
||||||
|
`env/staging.json` (see the `_TODO` key there).
|
||||||
|
|
||||||
|
The `applicationId` is set in `android/app/build.gradle.kts` via
|
||||||
|
`applicationIdSuffix` (dev/staging) on top of the base `com.mybestie`; prod has
|
||||||
|
no suffix. The app name comes from a per-flavor `resValue("string","app_name",…)`
|
||||||
|
consumed by `android:label="@string/app_name"` in `AndroidManifest.xml`.
|
||||||
|
|
||||||
|
## iOS bundle identity — DIFFERENT base from Android ⚠️
|
||||||
|
|
||||||
|
The customer app already exists on the App Store under bundle ID
|
||||||
|
**`com.asc.hallobestie`** (published by a prior vendor — note the `asc` prefix
|
||||||
|
and `hallobestie` spelling). **Decision: v2 ships as an _update_ to that existing
|
||||||
|
listing**, so the iOS **prod** bundle ID must stay `com.asc.hallobestie` (you
|
||||||
|
cannot change a published app's bundle ID without it becoming a new app). The
|
||||||
|
iOS flavors therefore suffix **that** base, NOT the Android `com.mybestie` base:
|
||||||
|
|
||||||
|
| Flavor | Android `applicationId` | iOS bundle ID |
|
||||||
|
|---------|-------------------------|--------------------------------|
|
||||||
|
| dev | `com.mybestie.dev` | `com.asc.hallobestie.dev` |
|
||||||
|
| staging | `com.mybestie.staging` | `com.asc.hallobestie.staging` |
|
||||||
|
| prod | `com.mybestie` | `com.asc.hallobestie` |
|
||||||
|
|
||||||
|
A bundle ID differing across platforms is normal. What's done so far on iOS:
|
||||||
|
- `ios/Runner.xcodeproj/project.pbxproj` — base `PRODUCT_BUNDLE_IDENTIFIER` set
|
||||||
|
to `com.asc.hallobestie` (+ `com.asc.hallobestie.RunnerTests`). Once iOS
|
||||||
|
schemes are wired, dev/staging suffix off this base.
|
||||||
|
- `firebase_options_{dev,staging,prod}.dart` — `iosBundleId` set to the table
|
||||||
|
above. dev's iOS appId/apiKey are now PLACEHOLDERs (the old `com.mybestie`
|
||||||
|
iOS app no longer matches) — register `com.asc.hallobestie.dev` in the dev
|
||||||
|
project and paste the values.
|
||||||
|
- `ios/Runner/Info.plist` — `CFBundleURLName` aligned to `com.asc.hallobestie`
|
||||||
|
(the `halobestie://` deeplink scheme itself is unchanged).
|
||||||
|
|
||||||
|
Still TODO on iOS (the deferred Mac/Xcode work):
|
||||||
|
- The existing `ios/Runner/GoogleService-Info.plist` still lists `com.mybestie`
|
||||||
|
— replace per flavor once the iOS apps are registered.
|
||||||
|
- Xcode build configs + schemes to actually apply the `.dev`/`.staging` bundle
|
||||||
|
suffixes per build, with a copy-script selecting the right `GoogleService-Info.plist`.
|
||||||
|
- You need access to the **`asc` Apple Developer account** + its signing
|
||||||
|
certs/provisioning profiles to release the prod update.
|
||||||
|
|
||||||
|
> **mitra app is unaffected** — it's a brand-new app (`com.mybestie.mitra` +
|
||||||
|
> suffixes) on both platforms; only the customer iOS app inherits the legacy
|
||||||
|
> `com.asc.hallobestie` identity.
|
||||||
|
|
||||||
|
## How the pieces fit
|
||||||
|
|
||||||
|
| Concern | dev | staging | prod |
|
||||||
|
|----------------------|---------------------------------------|--------------------------------------------|-----------------------------------------|
|
||||||
|
| Dart entrypoint | `lib/main_dev.dart` | `lib/main_staging.dart` | `lib/main_prod.dart` |
|
||||||
|
| Firebase Dart opts | `lib/firebase/firebase_options_dev.dart` | `…_staging.dart` (PLACEHOLDER) | `…_prod.dart` (PLACEHOLDER) |
|
||||||
|
| Android Firebase cfg | `android/app/src/dev/google-services.json` | `android/app/src/staging/…` (MISSING) | `android/app/src/prod/…` (MISSING) |
|
||||||
|
| Env / dart-define | `env/dev.json` | `env/staging.json` | `env/prod.json` |
|
||||||
|
|
||||||
|
A bare `flutter run` (no `-t`) still works and defaults to **dev**:
|
||||||
|
`lib/main.dart`'s `main()` delegates to `bootstrap(flavor: 'dev', …)`.
|
||||||
|
|
||||||
|
## Run / build commands
|
||||||
|
|
||||||
|
Each command needs three things: `--flavor <f>`, `-t lib/main_<f>.dart`, and
|
||||||
|
`--dart-define-from-file=env/<f>.json`.
|
||||||
|
|
||||||
|
### Run on a connected device/emulator
|
||||||
|
```bash
|
||||||
|
# dev
|
||||||
|
flutter run --flavor dev -t lib/main_dev.dart --dart-define-from-file=env/dev.json
|
||||||
|
|
||||||
|
# staging
|
||||||
|
flutter run --flavor staging -t lib/main_staging.dart --dart-define-from-file=env/staging.json
|
||||||
|
|
||||||
|
# prod
|
||||||
|
flutter run --flavor prod -t lib/main_prod.dart --dart-define-from-file=env/prod.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build a release APK
|
||||||
|
```bash
|
||||||
|
# dev
|
||||||
|
flutter build apk --flavor dev -t lib/main_dev.dart --dart-define-from-file=env/dev.json
|
||||||
|
|
||||||
|
# staging
|
||||||
|
flutter build apk --flavor staging -t lib/main_staging.dart --dart-define-from-file=env/staging.json
|
||||||
|
|
||||||
|
# prod
|
||||||
|
flutter build apk --flavor prod -t lib/main_prod.dart --dart-define-from-file=env/prod.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build an App Bundle (Play release)
|
||||||
|
```bash
|
||||||
|
flutter build appbundle --flavor prod -t lib/main_prod.dart --dart-define-from-file=env/prod.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Output APKs land in `build/app/outputs/flutter-apk/app-<flavor>-release.apk`.
|
||||||
|
|
||||||
|
## ⚠️ CRITICAL warnings
|
||||||
|
|
||||||
|
### (a) Every build/install/run command now needs `--flavor`
|
||||||
|
Once product flavors exist, a bare `flutter build apk` / `flutter install` /
|
||||||
|
`flutter run` **with no `--flavor`** FAILS (Gradle can't pick a flavor). All
|
||||||
|
build, install, and run invocations — CI scripts, local muscle memory, IDE
|
||||||
|
launch configs — must pass `--flavor <dev|staging|prod>` plus the matching
|
||||||
|
`-t lib/main_<flavor>.dart` and `--dart-define-from-file=env/<flavor>.json`.
|
||||||
|
|
||||||
|
### (b) The dev applicationId is now `com.mybestie.dev`, not `com.mybestie`
|
||||||
|
Anything that references the package name by string must be updated for dev:
|
||||||
|
|
||||||
|
- **GA4 DebugView:**
|
||||||
|
```bash
|
||||||
|
adb shell setprop debug.firebase.analytics.app com.mybestie.dev
|
||||||
|
```
|
||||||
|
- **adb commands** (force-stop, pm clear, am start, grant, etc.):
|
||||||
|
```bash
|
||||||
|
adb shell am force-stop com.mybestie.dev
|
||||||
|
adb shell pm clear com.mybestie.dev
|
||||||
|
```
|
||||||
|
- **Maestro flows:** any `appId: com.mybestie` must become `appId: com.mybestie.dev`
|
||||||
|
for the dev build. Check `.maestro/` configs and per-flow `appId` headers.
|
||||||
|
|
||||||
|
Staging uses `com.mybestie.staging`; prod stays `com.mybestie`.
|
||||||
|
|
||||||
|
## Firebase config — STATUS: configured ✅ (2026-06-04)
|
||||||
|
|
||||||
|
All apps are registered and config files are in place for **both platforms**,
|
||||||
|
across 3 projects (one Firebase project per env):
|
||||||
|
|
||||||
|
| Env | Firebase project | Android applicationId | iOS bundle ID |
|
||||||
|
|---------|------------------------|------------------------|-------------------------------|
|
||||||
|
| dev | `halobestie-clone-dev` | `com.mybestie.dev` | `com.asc.hallobestie.dev` |
|
||||||
|
| staging | `my-bestie-876ec` | `com.mybestie.staging` | `com.asc.hallobestie.staging` |
|
||||||
|
| prod | `my-bestie-production` | `com.mybestie` | `com.asc.hallobestie` |
|
||||||
|
|
||||||
|
In place and verified:
|
||||||
|
- `android/app/src/<flavor>/google-services.json` — all 3, each containing a
|
||||||
|
client matching the flavor `applicationId`.
|
||||||
|
- `ios/config/<flavor>/GoogleService-Info.plist` — all 3, bundle IDs verified.
|
||||||
|
- `lib/firebase/firebase_options_{dev,staging,prod}.dart` — real android + iOS
|
||||||
|
values (regenerated from the native files above; no placeholders left).
|
||||||
|
|
||||||
|
### Regenerating after any ID / key change
|
||||||
|
Either re-run flutterfire per flavor, or re-download the native files and
|
||||||
|
re-extract. Project IDs:
|
||||||
|
```bash
|
||||||
|
flutterfire configure --project=halobestie-clone-dev --out=lib/firebase/firebase_options_dev.dart
|
||||||
|
flutterfire configure --project=my-bestie-876ec --out=lib/firebase/firebase_options_staging.dart
|
||||||
|
flutterfire configure --project=my-bestie-production --out=lib/firebase/firebase_options_prod.dart
|
||||||
|
```
|
||||||
|
|
||||||
|
### Still TODO — iOS only (Mac/Xcode)
|
||||||
|
- [ ] iOS **Xcode schemes + build-phase copy script** to apply per-flavor bundle
|
||||||
|
suffixes and select the right `GoogleService-Info.plist` at build time.
|
||||||
|
Until then, iOS bundles only `ios/Runner/GoogleService-Info.plist`.
|
||||||
|
- [ ] Replace `ios/Runner/GoogleService-Info.plist` (still the legacy
|
||||||
|
`com.mybestie` dev file) — or let the copy script overwrite it per build.
|
||||||
|
- [ ] Prod iOS release needs the **`asc` Apple Developer account** + signing.
|
||||||
|
|
||||||
|
## Files in this setup
|
||||||
|
|
||||||
|
- `android/app/build.gradle.kts` — `flavorDimensions` + `productFlavors`
|
||||||
|
- `android/app/src/main/AndroidManifest.xml` — `android:label="@string/app_name"`
|
||||||
|
- `lib/main.dart` — `bootstrap()` + dev-default `main()`
|
||||||
|
- `lib/main_dev.dart` / `lib/main_staging.dart` / `lib/main_prod.dart`
|
||||||
|
- `lib/firebase/firebase_options_{dev,staging,prod}.dart`
|
||||||
|
- `env/{dev,staging,prod}.json`
|
||||||
|
- `android/app/src/{dev,staging,prod}/` Firebase config source sets
|
||||||
@@ -24,7 +24,10 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
// Base application ID. Per-flavor suffixes below produce the final IDs:
|
||||||
|
// dev -> com.mybestie.dev
|
||||||
|
// staging -> com.mybestie.staging
|
||||||
|
// prod -> com.mybestie
|
||||||
applicationId = "com.mybestie"
|
applicationId = "com.mybestie"
|
||||||
// You can update the following values to match your application needs.
|
// You can update the following values to match your application needs.
|
||||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||||
@@ -34,6 +37,34 @@ android {
|
|||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build flavors for the three environments. Each flavor:
|
||||||
|
// - sets its final applicationId (via suffix, except prod)
|
||||||
|
// - injects an `app_name` string resource consumed by
|
||||||
|
// AndroidManifest.xml's android:label="@string/app_name"
|
||||||
|
// - selects its own Firebase config via the matching source set
|
||||||
|
// (android/app/src/<flavor>/google-services.json)
|
||||||
|
// NOTE: once these flavors exist, a bare `flutter build apk` (no --flavor)
|
||||||
|
// fails. All build/install/run commands MUST pass --flavor. See
|
||||||
|
// BUILD_FLAVORS.md.
|
||||||
|
flavorDimensions += "env"
|
||||||
|
productFlavors {
|
||||||
|
create("dev") {
|
||||||
|
dimension = "env"
|
||||||
|
applicationIdSuffix = ".dev"
|
||||||
|
resValue("string", "app_name", "HaloBestie Dev")
|
||||||
|
}
|
||||||
|
create("staging") {
|
||||||
|
dimension = "env"
|
||||||
|
applicationIdSuffix = ".staging"
|
||||||
|
resValue("string", "app_name", "HaloBestie Staging")
|
||||||
|
}
|
||||||
|
create("prod") {
|
||||||
|
dimension = "env"
|
||||||
|
// No applicationIdSuffix -> final applicationId stays "com.mybestie".
|
||||||
|
resValue("string", "app_name", "HaloBestie")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
// TODO: Add your own signing config for the release build.
|
// TODO: Add your own signing config for the release build.
|
||||||
|
|||||||
@@ -62,6 +62,25 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"client_info": {
|
||||||
|
"mobilesdk_app_id": "1:1068156046511:android:1f589ed358ccdad0b8185a",
|
||||||
|
"android_client_info": {
|
||||||
|
"package_name": "com.mybestie.dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oauth_client": [],
|
||||||
|
"api_key": [
|
||||||
|
{
|
||||||
|
"current_key": "AIzaSyDFWlLSWytqwI7LSdUbVrO7J5De9L2LV2U"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": {
|
||||||
|
"appinvite_service": {
|
||||||
|
"other_platform_oauth_client": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"client_info": {
|
"client_info": {
|
||||||
"mobilesdk_app_id": "1:1068156046511:android:8a873c8b7e64410ab8185a",
|
"mobilesdk_app_id": "1:1068156046511:android:8a873c8b7e64410ab8185a",
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
Phase 4 Stage 4 notif-gate via permission_handler. -->
|
Phase 4 Stage 4 notif-gate via permission_handler. -->
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
<application
|
<application
|
||||||
android:label="HaloBestie"
|
android:label="@string/app_name"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:usesCleartextTraffic="true">
|
android:usesCleartextTraffic="true">
|
||||||
|
|||||||
70
client_app/android/app/src/prod/google-services.json
Executable file
70
client_app/android/app/src/prod/google-services.json
Executable file
@@ -0,0 +1,70 @@
|
|||||||
|
{
|
||||||
|
"project_info": {
|
||||||
|
"project_number": "953866659887",
|
||||||
|
"project_id": "my-bestie-production",
|
||||||
|
"storage_bucket": "my-bestie-production.firebasestorage.app"
|
||||||
|
},
|
||||||
|
"client": [
|
||||||
|
{
|
||||||
|
"client_info": {
|
||||||
|
"mobilesdk_app_id": "1:953866659887:android:55dfbf97ac7c26e7183eda",
|
||||||
|
"android_client_info": {
|
||||||
|
"package_name": "com.mybestie"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oauth_client": [
|
||||||
|
{
|
||||||
|
"client_id": "953866659887-5a62u0tdce92i0gmfo0gf3dt0dnlre42.apps.googleusercontent.com",
|
||||||
|
"client_type": 1,
|
||||||
|
"android_info": {
|
||||||
|
"package_name": "com.mybestie",
|
||||||
|
"certificate_hash": "3c43db3c9ac7f6d7e2fa03b8dbcaf7e5d12c97f3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"client_id": "953866659887-kebg3eijcomtv97q6v03fm8i30kj7r9r.apps.googleusercontent.com",
|
||||||
|
"client_type": 1,
|
||||||
|
"android_info": {
|
||||||
|
"package_name": "com.mybestie",
|
||||||
|
"certificate_hash": "7119b6cf7091074759450c899191905a5a4d0369"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"client_id": "953866659887-vvur2mnmbu8ljmnmmg01hqsrj0ocssu9.apps.googleusercontent.com",
|
||||||
|
"client_type": 1,
|
||||||
|
"android_info": {
|
||||||
|
"package_name": "com.mybestie",
|
||||||
|
"certificate_hash": "6e23f0164af04ddf200f769c460caac3ee2b91ac"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"client_id": "953866659887-9ro36er68qupv00rgokjignnu2hs85v8.apps.googleusercontent.com",
|
||||||
|
"client_type": 3
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"api_key": [
|
||||||
|
{
|
||||||
|
"current_key": "AIzaSyAxAp8hcXO-P7HwwVsS3vFe0OX5ZkIyyWI"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": {
|
||||||
|
"appinvite_service": {
|
||||||
|
"other_platform_oauth_client": [
|
||||||
|
{
|
||||||
|
"client_id": "953866659887-9ro36er68qupv00rgokjignnu2hs85v8.apps.googleusercontent.com",
|
||||||
|
"client_type": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"client_id": "953866659887-bsb3c2a6ir10u47q8vcacre2tmnk59jb.apps.googleusercontent.com",
|
||||||
|
"client_type": 2,
|
||||||
|
"ios_info": {
|
||||||
|
"bundle_id": "com.asc.hallobestie"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"configuration_version": "1"
|
||||||
|
}
|
||||||
36
client_app/android/app/src/prod/google-services.json.README
Normal file
36
client_app/android/app/src/prod/google-services.json.README
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
PROD google-services.json — NOT YET PRESENT
|
||||||
|
===========================================
|
||||||
|
|
||||||
|
The Gradle google-services plugin reads this flavor's Firebase config from:
|
||||||
|
|
||||||
|
android/app/src/prod/google-services.json <-- MISSING
|
||||||
|
|
||||||
|
This file is deliberately a README, NOT a fabricated google-services.json.
|
||||||
|
A wrong/placeholder google-services.json compiles fine but SILENTLY breaks
|
||||||
|
Firebase Analytics and FCM at runtime (no build error, no crash — just no
|
||||||
|
events / no push). Do not invent one.
|
||||||
|
|
||||||
|
How to produce the real file
|
||||||
|
----------------------------
|
||||||
|
1. In the Firebase Console for the PRODUCTION project, register an Android app
|
||||||
|
with package name:
|
||||||
|
|
||||||
|
com.mybestie
|
||||||
|
|
||||||
|
(no suffix — prod is the un-suffixed applicationId). Use the SEPARATE prod
|
||||||
|
Firebase console, NOT halobestie-clone-dev. See BUILD_FLAVORS.md.
|
||||||
|
|
||||||
|
2. Download the generated google-services.json.
|
||||||
|
|
||||||
|
3. Drop it here, replacing this README:
|
||||||
|
|
||||||
|
android/app/src/prod/google-services.json
|
||||||
|
|
||||||
|
4. Also generate the Dart options:
|
||||||
|
|
||||||
|
flutterfire configure --project=<prod-project> \
|
||||||
|
--out=lib/firebase/firebase_options_prod.dart
|
||||||
|
|
||||||
|
(firebase_options_prod.dart currently holds PLACEHOLDER values.)
|
||||||
|
|
||||||
|
Until both files hold real values, do NOT release a production build.
|
||||||
106
client_app/android/app/src/staging/google-services.json
Executable file
106
client_app/android/app/src/staging/google-services.json
Executable file
@@ -0,0 +1,106 @@
|
|||||||
|
{
|
||||||
|
"project_info": {
|
||||||
|
"project_number": "650461407929",
|
||||||
|
"project_id": "my-bestie-876ec",
|
||||||
|
"storage_bucket": "my-bestie-876ec.firebasestorage.app"
|
||||||
|
},
|
||||||
|
"client": [
|
||||||
|
{
|
||||||
|
"client_info": {
|
||||||
|
"mobilesdk_app_id": "1:650461407929:android:92d95eb766802bcf504968",
|
||||||
|
"android_client_info": {
|
||||||
|
"package_name": "com.mybestie"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oauth_client": [
|
||||||
|
{
|
||||||
|
"client_id": "650461407929-ofuff9cv8d4fj79efj9e2ovt2nj9b83a.apps.googleusercontent.com",
|
||||||
|
"client_type": 3
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"api_key": [
|
||||||
|
{
|
||||||
|
"current_key": "AIzaSyAOPkPJkHXLFzo9ICOHyjee2Vn_EUqt1Pc"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": {
|
||||||
|
"appinvite_service": {
|
||||||
|
"other_platform_oauth_client": [
|
||||||
|
{
|
||||||
|
"client_id": "650461407929-ofuff9cv8d4fj79efj9e2ovt2nj9b83a.apps.googleusercontent.com",
|
||||||
|
"client_type": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"client_id": "650461407929-2o7eo5ts2k389pa3l16sq26l3107b52f.apps.googleusercontent.com",
|
||||||
|
"client_type": 2,
|
||||||
|
"ios_info": {
|
||||||
|
"bundle_id": "com.example.halloBestie"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"client_info": {
|
||||||
|
"mobilesdk_app_id": "1:650461407929:android:05754df9552e0529504968",
|
||||||
|
"android_client_info": {
|
||||||
|
"package_name": "com.mybestie.staging"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oauth_client": [
|
||||||
|
{
|
||||||
|
"client_id": "650461407929-3els6l9cegtiphe930vtjr80ffa2p1uv.apps.googleusercontent.com",
|
||||||
|
"client_type": 1,
|
||||||
|
"android_info": {
|
||||||
|
"package_name": "com.mybestie.staging",
|
||||||
|
"certificate_hash": "a46c19a615b3c21b529240dabc8f1cd68bcbd449"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"client_id": "650461407929-8lo71sr668gvvj0ntpjjemoqrkr82uid.apps.googleusercontent.com",
|
||||||
|
"client_type": 1,
|
||||||
|
"android_info": {
|
||||||
|
"package_name": "com.mybestie.staging",
|
||||||
|
"certificate_hash": "6e23f0164af04ddf200f769c460caac3ee2b91ac"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"client_id": "650461407929-lmj6n5jt818fkdjhjpbhabdd19g82f48.apps.googleusercontent.com",
|
||||||
|
"client_type": 1,
|
||||||
|
"android_info": {
|
||||||
|
"package_name": "com.mybestie.staging",
|
||||||
|
"certificate_hash": "937ecfa181a695a5f1fb5d04df15e490c174caea"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"client_id": "650461407929-ofuff9cv8d4fj79efj9e2ovt2nj9b83a.apps.googleusercontent.com",
|
||||||
|
"client_type": 3
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"api_key": [
|
||||||
|
{
|
||||||
|
"current_key": "AIzaSyAOPkPJkHXLFzo9ICOHyjee2Vn_EUqt1Pc"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": {
|
||||||
|
"appinvite_service": {
|
||||||
|
"other_platform_oauth_client": [
|
||||||
|
{
|
||||||
|
"client_id": "650461407929-ofuff9cv8d4fj79efj9e2ovt2nj9b83a.apps.googleusercontent.com",
|
||||||
|
"client_type": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"client_id": "650461407929-2o7eo5ts2k389pa3l16sq26l3107b52f.apps.googleusercontent.com",
|
||||||
|
"client_type": 2,
|
||||||
|
"ios_info": {
|
||||||
|
"bundle_id": "com.example.halloBestie"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"configuration_version": "1"
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
STAGING google-services.json — NOT YET PRESENT
|
||||||
|
==============================================
|
||||||
|
|
||||||
|
The Gradle google-services plugin reads this flavor's Firebase config from:
|
||||||
|
|
||||||
|
android/app/src/staging/google-services.json <-- MISSING
|
||||||
|
|
||||||
|
This file is deliberately a README, NOT a fabricated google-services.json.
|
||||||
|
A wrong/placeholder google-services.json compiles fine but SILENTLY breaks
|
||||||
|
Firebase Analytics and FCM at runtime (no build error, no crash — just no
|
||||||
|
events / no push). Do not invent one.
|
||||||
|
|
||||||
|
How to produce the real file
|
||||||
|
----------------------------
|
||||||
|
1. In the Firebase Console for the STAGING project, register an Android app
|
||||||
|
with package name:
|
||||||
|
|
||||||
|
com.mybestie.staging
|
||||||
|
|
||||||
|
(this is the dev/staging Firebase project today — see BUILD_FLAVORS.md for
|
||||||
|
the "register com.mybestie.staging in halobestie-clone-dev" decision, or a
|
||||||
|
dedicated staging project if/when one exists).
|
||||||
|
|
||||||
|
2. Download the generated google-services.json.
|
||||||
|
|
||||||
|
3. Drop it here, replacing this README:
|
||||||
|
|
||||||
|
android/app/src/staging/google-services.json
|
||||||
|
|
||||||
|
4. Also generate the Dart options:
|
||||||
|
|
||||||
|
flutterfire configure --project=<staging-project> \
|
||||||
|
--out=lib/firebase/firebase_options_staging.dart
|
||||||
|
|
||||||
|
(firebase_options_staging.dart currently holds PLACEHOLDER values.)
|
||||||
|
|
||||||
|
Until both files hold real values, do NOT distribute a staging build.
|
||||||
@@ -21,7 +21,7 @@ plugins {
|
|||||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||||
id("com.android.application") version "8.11.1" apply false
|
id("com.android.application") version "8.11.1" apply false
|
||||||
// START: FlutterFire Configuration
|
// START: FlutterFire Configuration
|
||||||
id("com.google.gms.google-services") version("4.3.10") apply false
|
id("com.google.gms.google-services") version("4.4.2") apply false
|
||||||
// END: FlutterFire Configuration
|
// END: FlutterFire Configuration
|
||||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||||
}
|
}
|
||||||
|
|||||||
4
client_app/env/dev.json
vendored
Normal file
4
client_app/env/dev.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"API_BASE_URL": "http://192.168.88.247:3000",
|
||||||
|
"FLAVOR": "dev"
|
||||||
|
}
|
||||||
4
client_app/env/prod.json
vendored
Normal file
4
client_app/env/prod.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"API_BASE_URL": "https://api.halobestie.com",
|
||||||
|
"FLAVOR": "prod"
|
||||||
|
}
|
||||||
5
client_app/env/staging.json
vendored
Normal file
5
client_app/env/staging.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"API_BASE_URL": "https://staging-api.halobestie.com",
|
||||||
|
"FLAVOR": "staging",
|
||||||
|
"_TODO": "confirm staging URL — https://staging-api.halobestie.com is a PLACEHOLDER (JSON forbids comments; this key documents the TODO and is ignored by the app)"
|
||||||
|
}
|
||||||
@@ -496,7 +496,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.mybestie;
|
PRODUCT_BUNDLE_IDENTIFIER = com.asc.hallobestie;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
@@ -513,7 +513,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.mybestie.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.asc.hallobestie.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
@@ -531,7 +531,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.mybestie.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.asc.hallobestie.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||||
@@ -547,7 +547,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.mybestie.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.asc.hallobestie.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||||
@@ -679,7 +679,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.mybestie;
|
PRODUCT_BUNDLE_IDENTIFIER = com.asc.hallobestie;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
@@ -702,7 +702,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.mybestie;
|
PRODUCT_BUNDLE_IDENTIFIER = com.asc.hallobestie;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
|
|||||||
@@ -87,7 +87,7 @@
|
|||||||
<array>
|
<array>
|
||||||
<dict>
|
<dict>
|
||||||
<key>CFBundleURLName</key>
|
<key>CFBundleURLName</key>
|
||||||
<string>com.mybestie</string>
|
<string>com.asc.hallobestie</string>
|
||||||
<key>CFBundleURLSchemes</key>
|
<key>CFBundleURLSchemes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>halobestie</string>
|
<string>halobestie</string>
|
||||||
|
|||||||
34
client_app/ios/config/README.md
Normal file
34
client_app/ios/config/README.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# iOS per-flavor Firebase config — client_app
|
||||||
|
|
||||||
|
iOS has **no** automatic per-flavor resolution (unlike Android's
|
||||||
|
`android/app/src/<flavor>/google-services.json`). Stage the three
|
||||||
|
`GoogleService-Info.plist` files here, one per flavor:
|
||||||
|
|
||||||
|
```
|
||||||
|
ios/config/
|
||||||
|
dev/GoogleService-Info.plist → bundle com.asc.hallobestie.dev (dev project)
|
||||||
|
staging/GoogleService-Info.plist → bundle com.asc.hallobestie.staging (staging/nonprod)
|
||||||
|
prod/GoogleService-Info.plist → bundle com.asc.hallobestie (prod project, live App Store app)
|
||||||
|
```
|
||||||
|
|
||||||
|
> The customer iOS bundle base is **`com.asc.hallobestie`** (the existing App
|
||||||
|
> Store app), NOT the Android `com.mybestie`. See `../../BUILD_FLAVORS.md`.
|
||||||
|
|
||||||
|
## Wiring (Xcode — Mac follow-up, not done yet)
|
||||||
|
|
||||||
|
The active plist Xcode bundles is `ios/Runner/GoogleService-Info.plist`. To make
|
||||||
|
it per-flavor, add a **Run Script** build phase to the Runner target, placed
|
||||||
|
**before** "Compile Sources":
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp "${PROJECT_DIR}/config/${FLAVOR}/GoogleService-Info.plist" \
|
||||||
|
"${PROJECT_DIR}/Runner/GoogleService-Info.plist"
|
||||||
|
```
|
||||||
|
|
||||||
|
`${FLAVOR}` is a per-scheme/configuration build setting you define when creating
|
||||||
|
the dev/staging/prod schemes (e.g. `FLAVOR = dev` in the dev configuration).
|
||||||
|
|
||||||
|
### Single-env shortcut
|
||||||
|
If you only need one env working (e.g. dev) before the full scheme setup, just
|
||||||
|
drop that env's plist directly at `ios/Runner/GoogleService-Info.plist` — no
|
||||||
|
script needed for a single environment.
|
||||||
30
client_app/ios/config/dev/GoogleService-Info.plist
Executable file
30
client_app/ios/config/dev/GoogleService-Info.plist
Executable file
@@ -0,0 +1,30 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>API_KEY</key>
|
||||||
|
<string>AIzaSyAQnB5hbj0T5tE4JQZQ9Tx6Whp_u15obMI</string>
|
||||||
|
<key>GCM_SENDER_ID</key>
|
||||||
|
<string>1068156046511</string>
|
||||||
|
<key>PLIST_VERSION</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>BUNDLE_ID</key>
|
||||||
|
<string>com.asc.hallobestie.dev</string>
|
||||||
|
<key>PROJECT_ID</key>
|
||||||
|
<string>halobestie-clone-dev</string>
|
||||||
|
<key>STORAGE_BUCKET</key>
|
||||||
|
<string>halobestie-clone-dev.firebasestorage.app</string>
|
||||||
|
<key>IS_ADS_ENABLED</key>
|
||||||
|
<false></false>
|
||||||
|
<key>IS_ANALYTICS_ENABLED</key>
|
||||||
|
<false></false>
|
||||||
|
<key>IS_APPINVITE_ENABLED</key>
|
||||||
|
<true></true>
|
||||||
|
<key>IS_GCM_ENABLED</key>
|
||||||
|
<true></true>
|
||||||
|
<key>IS_SIGNIN_ENABLED</key>
|
||||||
|
<true></true>
|
||||||
|
<key>GOOGLE_APP_ID</key>
|
||||||
|
<string>1:1068156046511:ios:bc9098ffc2c2913ab8185a</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
15
client_app/ios/config/dev/GoogleService-Info.plist.README
Normal file
15
client_app/ios/config/dev/GoogleService-Info.plist.README
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
Place the DEV GoogleService-Info.plist here, named exactly:
|
||||||
|
|
||||||
|
GoogleService-Info.plist
|
||||||
|
|
||||||
|
Register this iOS app first:
|
||||||
|
• Firebase project: halobestie-clone-dev (the DEV project)
|
||||||
|
• iOS bundle ID: com.asc.hallobestie.dev
|
||||||
|
Then download its GoogleService-Info.plist into this folder (delete this .README).
|
||||||
|
|
||||||
|
Also paste its appId/apiKey into lib/firebase/firebase_options_dev.dart
|
||||||
|
(the iOS section currently holds PLACEHOLDER values).
|
||||||
|
|
||||||
|
How it gets used: a build-phase "Run Script" (added in Xcode — Mac follow-up)
|
||||||
|
copies the per-flavor file into ios/Runner/GoogleService-Info.plist at build
|
||||||
|
time. See ../README.md and BUILD_FLAVORS.md.
|
||||||
36
client_app/ios/config/prod/GoogleService-Info.plist
Executable file
36
client_app/ios/config/prod/GoogleService-Info.plist
Executable file
@@ -0,0 +1,36 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CLIENT_ID</key>
|
||||||
|
<string>953866659887-bsb3c2a6ir10u47q8vcacre2tmnk59jb.apps.googleusercontent.com</string>
|
||||||
|
<key>REVERSED_CLIENT_ID</key>
|
||||||
|
<string>com.googleusercontent.apps.953866659887-bsb3c2a6ir10u47q8vcacre2tmnk59jb</string>
|
||||||
|
<key>ANDROID_CLIENT_ID</key>
|
||||||
|
<string>953866659887-5a62u0tdce92i0gmfo0gf3dt0dnlre42.apps.googleusercontent.com</string>
|
||||||
|
<key>API_KEY</key>
|
||||||
|
<string>AIzaSyA8guPSD87eDLeCsH6jVd1n2_SI4_MaGNE</string>
|
||||||
|
<key>GCM_SENDER_ID</key>
|
||||||
|
<string>953866659887</string>
|
||||||
|
<key>PLIST_VERSION</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>BUNDLE_ID</key>
|
||||||
|
<string>com.asc.hallobestie</string>
|
||||||
|
<key>PROJECT_ID</key>
|
||||||
|
<string>my-bestie-production</string>
|
||||||
|
<key>STORAGE_BUCKET</key>
|
||||||
|
<string>my-bestie-production.firebasestorage.app</string>
|
||||||
|
<key>IS_ADS_ENABLED</key>
|
||||||
|
<false></false>
|
||||||
|
<key>IS_ANALYTICS_ENABLED</key>
|
||||||
|
<false></false>
|
||||||
|
<key>IS_APPINVITE_ENABLED</key>
|
||||||
|
<true></true>
|
||||||
|
<key>IS_GCM_ENABLED</key>
|
||||||
|
<true></true>
|
||||||
|
<key>IS_SIGNIN_ENABLED</key>
|
||||||
|
<true></true>
|
||||||
|
<key>GOOGLE_APP_ID</key>
|
||||||
|
<string>1:953866659887:ios:159fd11b1d2f3633183eda</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
16
client_app/ios/config/prod/GoogleService-Info.plist.README
Normal file
16
client_app/ios/config/prod/GoogleService-Info.plist.README
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
Place the PROD GoogleService-Info.plist here, named exactly:
|
||||||
|
|
||||||
|
GoogleService-Info.plist
|
||||||
|
|
||||||
|
Register this iOS app first:
|
||||||
|
• Firebase project: the PRODUCTION project
|
||||||
|
• iOS bundle ID: com.asc.hallobestie (the EXISTING App Store app — v2 ships
|
||||||
|
as an update to it; needs the `asc` Apple Developer account)
|
||||||
|
Then download its GoogleService-Info.plist into this folder (delete this .README).
|
||||||
|
|
||||||
|
Also paste its values into lib/firebase/firebase_options_prod.dart
|
||||||
|
(currently all PLACEHOLDER).
|
||||||
|
|
||||||
|
How it gets used: a build-phase "Run Script" (added in Xcode — Mac follow-up)
|
||||||
|
copies the per-flavor file into ios/Runner/GoogleService-Info.plist at build
|
||||||
|
time. See ../README.md and BUILD_FLAVORS.md.
|
||||||
36
client_app/ios/config/staging/GoogleService-Info.plist
Executable file
36
client_app/ios/config/staging/GoogleService-Info.plist
Executable file
@@ -0,0 +1,36 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CLIENT_ID</key>
|
||||||
|
<string>650461407929-fb4t48nmguaslfvis7ebsea8vndro7ac.apps.googleusercontent.com</string>
|
||||||
|
<key>REVERSED_CLIENT_ID</key>
|
||||||
|
<string>com.googleusercontent.apps.650461407929-fb4t48nmguaslfvis7ebsea8vndro7ac</string>
|
||||||
|
<key>ANDROID_CLIENT_ID</key>
|
||||||
|
<string>650461407929-3els6l9cegtiphe930vtjr80ffa2p1uv.apps.googleusercontent.com</string>
|
||||||
|
<key>API_KEY</key>
|
||||||
|
<string>AIzaSyC_BewS88iaNsc9QdwsPzkV0sf9wUs4i_4</string>
|
||||||
|
<key>GCM_SENDER_ID</key>
|
||||||
|
<string>650461407929</string>
|
||||||
|
<key>PLIST_VERSION</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>BUNDLE_ID</key>
|
||||||
|
<string>com.asc.hallobestie.staging</string>
|
||||||
|
<key>PROJECT_ID</key>
|
||||||
|
<string>my-bestie-876ec</string>
|
||||||
|
<key>STORAGE_BUCKET</key>
|
||||||
|
<string>my-bestie-876ec.firebasestorage.app</string>
|
||||||
|
<key>IS_ADS_ENABLED</key>
|
||||||
|
<false></false>
|
||||||
|
<key>IS_ANALYTICS_ENABLED</key>
|
||||||
|
<false></false>
|
||||||
|
<key>IS_APPINVITE_ENABLED</key>
|
||||||
|
<true></true>
|
||||||
|
<key>IS_GCM_ENABLED</key>
|
||||||
|
<true></true>
|
||||||
|
<key>IS_SIGNIN_ENABLED</key>
|
||||||
|
<true></true>
|
||||||
|
<key>GOOGLE_APP_ID</key>
|
||||||
|
<string>1:650461407929:ios:4ee79d479b69d688504968</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
Place the STAGING GoogleService-Info.plist here, named exactly:
|
||||||
|
|
||||||
|
GoogleService-Info.plist
|
||||||
|
|
||||||
|
Register this iOS app first:
|
||||||
|
• Firebase project: the STAGING / nonprod project
|
||||||
|
• iOS bundle ID: com.asc.hallobestie.staging
|
||||||
|
Then download its GoogleService-Info.plist into this folder (delete this .README).
|
||||||
|
|
||||||
|
Also paste its values into lib/firebase/firebase_options_staging.dart
|
||||||
|
(currently all PLACEHOLDER).
|
||||||
|
|
||||||
|
How it gets used: a build-phase "Run Script" (added in Xcode — Mac follow-up)
|
||||||
|
copies the per-flavor file into ios/Runner/GoogleService-Info.plist at build
|
||||||
|
time. See ../README.md and BUILD_FLAVORS.md.
|
||||||
254
client_app/lib/core/analytics/analytics_service.dart
Normal file
254
client_app/lib/core/analytics/analytics_service.dart
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
import 'package:firebase_analytics/firebase_analytics.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
|
import '../../features/auth/widgets/verif_choice_sheet.dart' show VerifChoice;
|
||||||
|
|
||||||
|
part 'analytics_service.g.dart';
|
||||||
|
|
||||||
|
/// Which lifecycle funnel an event belongs to. GA4 reports filter on the
|
||||||
|
/// `funnel` custom dimension; the activation and repeat funnels deliberately
|
||||||
|
/// share event names and are split by this param (see
|
||||||
|
/// requirement/analytics-funnel-plan.md §2/§5).
|
||||||
|
enum AnalyticsFunnel {
|
||||||
|
activation('activation'),
|
||||||
|
repeat('repeat');
|
||||||
|
|
||||||
|
final String value;
|
||||||
|
const AnalyticsFunnel(this.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Identity method the user chose at the start of an auth flow. Low-cardinality
|
||||||
|
/// and PII-free — the value is the *channel*, never the phone/email itself.
|
||||||
|
enum AnalyticsAuthMethod {
|
||||||
|
phone('phone'),
|
||||||
|
google('google'),
|
||||||
|
apple('apple');
|
||||||
|
|
||||||
|
final String value;
|
||||||
|
const AnalyticsAuthMethod(this.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// User-property bucket for `user_type`. Anonymous = server-issued anon
|
||||||
|
/// customer with no identity; verified = phone/Google/Apple linked.
|
||||||
|
enum AnalyticsUserType {
|
||||||
|
anonymous('anonymous'),
|
||||||
|
verified('verified');
|
||||||
|
|
||||||
|
final String value;
|
||||||
|
const AnalyticsUserType(this.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stable screen names for the GoRouter observer. Keeping these as an enum
|
||||||
|
/// (rather than mapping raw `state.uri.path`) guarantees path params like
|
||||||
|
/// `:sessionId` / `:paymentId` / `:mitraId` never leak into `screen_name`.
|
||||||
|
enum AnalyticsScreen {
|
||||||
|
splash('splash'),
|
||||||
|
authDisplayName('auth_display_name'),
|
||||||
|
authRegister('auth_register'),
|
||||||
|
authOtp('auth_otp'),
|
||||||
|
authSetName('auth_set_name'),
|
||||||
|
authForceRegister('auth_force_register'),
|
||||||
|
onboardingUspVerified('onboarding_usp_verified'),
|
||||||
|
onboardingUspAnon('onboarding_usp_anon'),
|
||||||
|
onboardingNotifGate('onboarding_notif_gate'),
|
||||||
|
home('home'),
|
||||||
|
profile('profile'),
|
||||||
|
paymentEntry('payment_entry'),
|
||||||
|
paymentDiscountPaywall('payment_discount_paywall'),
|
||||||
|
// Route /payment/method-pick is actually the chat-vs-call MODE picker
|
||||||
|
// ("pilih cara curhat"), NOT the payment-channel picker (that's
|
||||||
|
// paymentMethod → /payment/method). Named accordingly to avoid funnel
|
||||||
|
// ambiguity between the two.
|
||||||
|
curhatModePick('curhat_mode_pick'),
|
||||||
|
paymentDurationPick('payment_duration_pick'),
|
||||||
|
paymentMethod('payment_method'),
|
||||||
|
paymentWaiting('payment_waiting'),
|
||||||
|
paymentExpired('payment_expired'),
|
||||||
|
chatSearching('chat_searching'),
|
||||||
|
chatFound('chat_found'),
|
||||||
|
chatNoBestie('chat_no_bestie'),
|
||||||
|
chatWaitingTargeted('chat_waiting_targeted'),
|
||||||
|
chatSession('chat_session'),
|
||||||
|
chatThankYou('chat_thank_you'),
|
||||||
|
chatTabAktif('chat_tab_aktif'),
|
||||||
|
chatTabPembayaran('chat_tab_pembayaran'),
|
||||||
|
chatTabSelesai('chat_tab_selesai'),
|
||||||
|
chatTranscript('chat_transcript'),
|
||||||
|
bestieHistory('bestie_history');
|
||||||
|
|
||||||
|
final String value;
|
||||||
|
const AnalyticsScreen(this.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Thin, typed façade over `FirebaseAnalytics.instance`.
|
||||||
|
///
|
||||||
|
/// Every funnel event in requirement/analytics-funnel-plan.md §5 is a named
|
||||||
|
/// method here so call sites never pass free-form strings — the only way to
|
||||||
|
/// log is through a method whose params are fixed and PII-free. New events get
|
||||||
|
/// added to the spec table first, then here (governance, §9).
|
||||||
|
class AnalyticsService {
|
||||||
|
AnalyticsService(this._analytics);
|
||||||
|
|
||||||
|
final FirebaseAnalytics _analytics;
|
||||||
|
|
||||||
|
// ── Identity & user properties (§4) ───────────────────────────────────────
|
||||||
|
|
||||||
|
/// Set the opaque customer UUID. Re-set on identity upgrade (anon→verified)
|
||||||
|
/// so the same `user_id` continues across the merge. Never a phone/name.
|
||||||
|
Future<void> setUserId(String? customerId) =>
|
||||||
|
_analytics.setUserId(id: customerId);
|
||||||
|
|
||||||
|
Future<void> setUserType(AnalyticsUserType type) =>
|
||||||
|
_analytics.setUserProperty(name: 'user_type', value: type.value);
|
||||||
|
|
||||||
|
Future<void> setIsReturning(bool isReturning) => _analytics.setUserProperty(
|
||||||
|
name: 'is_returning',
|
||||||
|
value: isReturning ? 'true' : 'false',
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Payment stitching helpers (§3) ────────────────────────────────────────
|
||||||
|
|
||||||
|
/// App-instance id required by the GA4 Measurement Protocol to stitch a
|
||||||
|
/// server-fired `payment_confirmed` back to this device's stream.
|
||||||
|
Future<String?> appInstanceId() => _analytics.appInstanceId;
|
||||||
|
|
||||||
|
/// Current GA session id — replayed server-side so the MP event lands in the
|
||||||
|
/// same session as the client funnel.
|
||||||
|
Future<int?> sessionId() => _analytics.getSessionId();
|
||||||
|
|
||||||
|
// ── Funnel events (§5) ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Activation: Home primary "aku mau curhat" CTA.
|
||||||
|
Future<void> logCurhatStart({String? entryPoint}) => _log('curhat_start', {
|
||||||
|
'funnel': AnalyticsFunnel.activation.value,
|
||||||
|
if (entryPoint != null) 'entry_point': entryPoint,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Repeat: returning "curhat sama bestie baru" / "curhat lagi" path.
|
||||||
|
Future<void> logCurhatRepeatStart() => _log('curhat_repeat_start', {
|
||||||
|
'funnel': AnalyticsFunnel.repeat.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Repeat: tapped a known bestie row in `/bestie/history`. `mitraRef` is an
|
||||||
|
/// opaque token (never the mitra's name/id surfaced to GA) — pass an
|
||||||
|
/// already-hashed/short ref or omit.
|
||||||
|
Future<void> logBestieReselect({String? mitraRef}) => _log('bestie_reselect', {
|
||||||
|
'funnel': AnalyticsFunnel.repeat.value,
|
||||||
|
if (mitraRef != null) 'mitra_ref': mitraRef,
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<void> logAuthStart(AnalyticsAuthMethod method) => _log('auth_start', {
|
||||||
|
'method': method.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<void> logAuthOtpSubmit() => _log('auth_otp_submit', const {});
|
||||||
|
|
||||||
|
Future<void> logAuthComplete(AnalyticsUserType userType) =>
|
||||||
|
_log('auth_complete', {'user_type': userType.value});
|
||||||
|
|
||||||
|
Future<void> logOnboardingUspView({required bool verified}) =>
|
||||||
|
_log('onboarding_usp_view', {'verified': verified});
|
||||||
|
|
||||||
|
Future<void> logPaymentView({
|
||||||
|
required AnalyticsFunnel funnel,
|
||||||
|
required bool isRepeat,
|
||||||
|
}) =>
|
||||||
|
_log('payment_view', {
|
||||||
|
'funnel': funnel.value,
|
||||||
|
'is_repeat': isRepeat,
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<void> logPaymentMethodSelect({required String method}) =>
|
||||||
|
_log('payment_method_select', {'method': method});
|
||||||
|
|
||||||
|
/// ⭐ Fired only AFTER the POST /payment-requests returns an id.
|
||||||
|
Future<void> logPaymentStarted({
|
||||||
|
required String paymentRequestId,
|
||||||
|
required int amount,
|
||||||
|
required String method,
|
||||||
|
required AnalyticsFunnel funnel,
|
||||||
|
required bool isRepeat,
|
||||||
|
required String productType,
|
||||||
|
required int durationMinutes,
|
||||||
|
String currency = 'IDR',
|
||||||
|
}) =>
|
||||||
|
_log('payment_started', {
|
||||||
|
'payment_request_id': paymentRequestId,
|
||||||
|
'amount': amount,
|
||||||
|
'currency': currency,
|
||||||
|
'method': method,
|
||||||
|
'funnel': funnel.value,
|
||||||
|
'is_repeat': isRepeat,
|
||||||
|
'product_type': productType,
|
||||||
|
'duration_minutes': durationMinutes,
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<void> logPairingMatched({required AnalyticsFunnel funnel}) =>
|
||||||
|
_log('pairing_matched', {'funnel': funnel.value});
|
||||||
|
|
||||||
|
Future<void> logPairingNoBestie({required AnalyticsFunnel funnel}) =>
|
||||||
|
_log('pairing_no_bestie', {'funnel': funnel.value});
|
||||||
|
|
||||||
|
// ── Bottom-sheet / modal funnel events (§5) ───────────────────────────────
|
||||||
|
|
||||||
|
/// Activation: post-name Verif Choice Sheet shown. Paired with
|
||||||
|
/// [logVerifChoiceSelect] — the gap between view and select is abandonment.
|
||||||
|
Future<void> logVerifChoiceView() =>
|
||||||
|
_log('verif_choice_view', const {});
|
||||||
|
|
||||||
|
/// Activation: user picked an identity branch in the Verif Choice Sheet.
|
||||||
|
/// Maps the [VerifChoice] enum to a low-cardinality, PII-free channel value.
|
||||||
|
Future<void> logVerifChoiceSelect(VerifChoice choice) =>
|
||||||
|
_log('verif_choice_select', {
|
||||||
|
'choice': switch (choice) {
|
||||||
|
VerifChoice.verified => 'verified',
|
||||||
|
VerifChoice.anonymous => 'anonymous',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Repeat: Bestie Choice Sheet shown from the returning-user Home CTA.
|
||||||
|
Future<void> logBestieChoiceView() =>
|
||||||
|
_log('bestie_choice_view', const {});
|
||||||
|
|
||||||
|
/// Repeat: user picked a bestie branch in the Bestie Choice Sheet.
|
||||||
|
Future<void> logBestieChoiceSelect({required bool knownBestie}) =>
|
||||||
|
_log('bestie_choice_select', {
|
||||||
|
'choice': knownBestie ? 'known_bestie' : 'new_bestie',
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Repeat: in-session extension upsell sheet shown (time-up / low-time).
|
||||||
|
Future<void> logExtensionOfferView({String? sessionId}) =>
|
||||||
|
_log('extension_offer_view', {
|
||||||
|
if (sessionId != null) 'session_id': sessionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Repeat: user confirmed an extension from the upsell sheet (not dismiss
|
||||||
|
/// or "akhiri sesi"). Fired the moment the perpanjang CTA is committed.
|
||||||
|
Future<void> logChatExtensionRequested({String? sessionId}) =>
|
||||||
|
_log('chat_extension_requested', {
|
||||||
|
if (sessionId != null) 'session_id': sessionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Internal ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<void> _log(String name, Map<String, Object?> params) async {
|
||||||
|
final clean = <String, Object>{
|
||||||
|
for (final e in params.entries)
|
||||||
|
if (e.value != null) e.key: e.value!,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await _analytics.logEvent(name: name, parameters: clean.isEmpty ? null : clean);
|
||||||
|
} catch (e) {
|
||||||
|
// Analytics must never break a user flow.
|
||||||
|
debugPrint('[analytics] failed to log $name: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keep-alive so a single AnalyticsService (and its FirebaseAnalytics handle)
|
||||||
|
/// is shared across screens/notifiers for the app's lifetime.
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
AnalyticsService analytics(Ref ref) =>
|
||||||
|
AnalyticsService(FirebaseAnalytics.instance);
|
||||||
29
client_app/lib/core/analytics/analytics_service.g.dart
Normal file
29
client_app/lib/core/analytics/analytics_service.g.dart
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'analytics_service.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// RiverpodGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
String _$analyticsHash() => r'862f59acd499543968f9ee591cec55cdd1b50035';
|
||||||
|
|
||||||
|
/// Keep-alive so a single AnalyticsService (and its FirebaseAnalytics handle)
|
||||||
|
/// is shared across screens/notifiers for the app's lifetime.
|
||||||
|
///
|
||||||
|
/// Copied from [analytics].
|
||||||
|
@ProviderFor(analytics)
|
||||||
|
final analyticsProvider = Provider<AnalyticsService>.internal(
|
||||||
|
analytics,
|
||||||
|
name: r'analyticsProvider',
|
||||||
|
debugGetCreateSourceHash:
|
||||||
|
const bool.fromEnvironment('dart.vm.product') ? null : _$analyticsHash,
|
||||||
|
dependencies: null,
|
||||||
|
allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||||
|
// ignore: unused_element
|
||||||
|
typedef AnalyticsRef = ProviderRef<AnalyticsService>;
|
||||||
|
// ignore_for_file: type=lint
|
||||||
|
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
|
||||||
@@ -6,7 +6,7 @@ part of 'auth_notifier.dart';
|
|||||||
// RiverpodGenerator
|
// RiverpodGenerator
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
String _$authHash() => r'f0382b1380749cebe8182ad221023926768ebb92';
|
String _$authHash() => r'76cd43babe5503d35a35c8fd23ba32afbc4c8c2d';
|
||||||
|
|
||||||
/// See also [Auth].
|
/// See also [Auth].
|
||||||
@ProviderFor(Auth)
|
@ProviderFor(Auth)
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ final chatRemainingSecondsProvider = AutoDisposeStreamProvider<int>.internal(
|
|||||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||||
// ignore: unused_element
|
// ignore: unused_element
|
||||||
typedef ChatRemainingSecondsRef = AutoDisposeStreamProviderRef<int>;
|
typedef ChatRemainingSecondsRef = AutoDisposeStreamProviderRef<int>;
|
||||||
String _$chatHash() => r'a42bf404f5b38034c6c9511259b64b1092af3caa';
|
String _$chatHash() => r'56f019ce6e527128ab42a71f56220c5412cfec0f';
|
||||||
|
|
||||||
/// See also [Chat].
|
/// See also [Chat].
|
||||||
@ProviderFor(Chat)
|
@ProviderFor(Chat)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ part of 'session_closure_notifier.dart';
|
|||||||
// RiverpodGenerator
|
// RiverpodGenerator
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
String _$sessionClosureHash() => r'a9aaf26da64d3489497d057e2b05741c08444e07';
|
String _$sessionClosureHash() => r'1ab9df044138115e232b3df494e2895177d9d66d';
|
||||||
|
|
||||||
/// See also [SessionClosure].
|
/// See also [SessionClosure].
|
||||||
@ProviderFor(SessionClosure)
|
@ProviderFor(SessionClosure)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ part of 'pairing_notifier.dart';
|
|||||||
// RiverpodGenerator
|
// RiverpodGenerator
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
String _$pairingHash() => r'd39980fe5b82348d03485006d0534ab597b93ceb';
|
String _$pairingHash() => r'e66bbf67e1013b3d25230ed01fe2595bff943b3a';
|
||||||
|
|
||||||
/// See also [Pairing].
|
/// See also [Pairing].
|
||||||
@ProviderFor(Pairing)
|
@ProviderFor(Pairing)
|
||||||
|
|||||||
96
client_app/lib/core/widgets/web_page_screen.dart
Normal file
96
client_app/lib/core/widgets/web_page_screen.dart
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:webview_flutter/webview_flutter.dart';
|
||||||
|
|
||||||
|
import '../theme/halo_tokens.dart';
|
||||||
|
|
||||||
|
/// Generic in-app WebView host for static external pages (privacy policy,
|
||||||
|
/// terms & conditions, etc).
|
||||||
|
///
|
||||||
|
/// Unlike [XenditCheckoutScreen] this carries no navigation-interception logic
|
||||||
|
/// — it just loads [url] and lets the user read it, with a close button and a
|
||||||
|
/// progress bar. Push it with a plain `Navigator.push(MaterialPageRoute(...))`.
|
||||||
|
class WebPageScreen extends StatefulWidget {
|
||||||
|
final String url;
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
const WebPageScreen({
|
||||||
|
super.key,
|
||||||
|
required this.url,
|
||||||
|
required this.title,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<WebPageScreen> createState() => _WebPageScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WebPageScreenState extends State<WebPageScreen> {
|
||||||
|
late final WebViewController _controller;
|
||||||
|
int _progress = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = WebViewController()
|
||||||
|
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||||
|
..setBackgroundColor(HaloTokens.surface)
|
||||||
|
..setNavigationDelegate(
|
||||||
|
NavigationDelegate(
|
||||||
|
onProgress: (p) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _progress = p);
|
||||||
|
},
|
||||||
|
onWebResourceError: (error) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint(
|
||||||
|
'[WebPageScreen] WebResourceError '
|
||||||
|
'code=${error.errorCode} type=${error.errorType} '
|
||||||
|
'desc=${error.description} url=${error.url}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
..loadRequest(Uri.parse(widget.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: HaloTokens.surface,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: HaloTokens.surface,
|
||||||
|
elevation: 0,
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.close, color: HaloTokens.brandDark),
|
||||||
|
tooltip: 'Tutup',
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
widget.title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: HaloTokens.fontDisplay,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: HaloTokens.brandDark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
centerTitle: true,
|
||||||
|
bottom: _progress < 100
|
||||||
|
? PreferredSize(
|
||||||
|
preferredSize: const Size.fromHeight(2),
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
value: _progress / 100.0,
|
||||||
|
minHeight: 2,
|
||||||
|
backgroundColor: HaloTokens.brandSofter,
|
||||||
|
valueColor: const AlwaysStoppedAnimation<Color>(
|
||||||
|
HaloTokens.brand,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
body: WebViewWidget(controller: _controller),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import '../../../core/analytics/analytics_service.dart';
|
||||||
import '../../../core/api/api_client_provider.dart';
|
import '../../../core/api/api_client_provider.dart';
|
||||||
import '../../../core/auth/auth_notifier.dart';
|
import '../../../core/auth/auth_notifier.dart';
|
||||||
import '../../../core/theme/halo_tokens.dart';
|
import '../../../core/theme/halo_tokens.dart';
|
||||||
@@ -35,6 +36,9 @@ class _DisplayNameScreenState extends ConsumerState<DisplayNameScreen> {
|
|||||||
final data = next.valueOrNull;
|
final data = next.valueOrNull;
|
||||||
if (data is AuthAnonymousData && !_routedAfterLogin) {
|
if (data is AuthAnonymousData && !_routedAfterLogin) {
|
||||||
_routedAfterLogin = true;
|
_routedAfterLogin = true;
|
||||||
|
// Anonymous identity established — activation step 6.
|
||||||
|
// ignore: discarded_futures
|
||||||
|
ref.read(analyticsProvider).logAuthComplete(AnalyticsUserType.anonymous);
|
||||||
_proceedAfterLogin();
|
_proceedAfterLogin();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -82,12 +86,17 @@ class _DisplayNameScreenState extends ConsumerState<DisplayNameScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ignore: discarded_futures
|
||||||
|
ref.read(analyticsProvider).logVerifChoiceView();
|
||||||
final choice = await VerifChoiceSheet.show(context);
|
final choice = await VerifChoiceSheet.show(context);
|
||||||
if (!mounted || choice == null) {
|
if (!mounted || choice == null) {
|
||||||
// User dismissed the sheet — let them tap Lanjut again to retry.
|
// User dismissed the sheet — let them tap Lanjut again to retry. No
|
||||||
|
// select event: the view→select gap is the abandonment signal.
|
||||||
_routedAfterLogin = false;
|
_routedAfterLogin = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// ignore: discarded_futures
|
||||||
|
ref.read(analyticsProvider).logVerifChoiceSelect(choice);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
await routeForVerifChoice(context, ref, choice);
|
await routeForVerifChoice(context, ref, choice);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../../../core/analytics/analytics_service.dart';
|
||||||
import '../../../core/api/api_client_provider.dart';
|
import '../../../core/api/api_client_provider.dart';
|
||||||
import '../../../core/auth/auth_notifier.dart';
|
import '../../../core/auth/auth_notifier.dart';
|
||||||
import '../../../core/constants.dart';
|
import '../../../core/constants.dart';
|
||||||
@@ -75,6 +76,14 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
|
|||||||
if (_errorMessage != null && mounted) {
|
if (_errorMessage != null && mounted) {
|
||||||
setState(() => _errorMessage = null);
|
setState(() => _errorMessage = null);
|
||||||
}
|
}
|
||||||
|
// OTP verify resolved to a real identity — activation/repeat step 6.
|
||||||
|
final data = next.valueOrNull;
|
||||||
|
if (data is AuthAuthenticatedData || data is AuthNeedsDisplayNameData) {
|
||||||
|
// ignore: discarded_futures
|
||||||
|
ref
|
||||||
|
.read(analyticsProvider)
|
||||||
|
.logAuthComplete(AnalyticsUserType.verified);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -152,6 +161,8 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
|
|||||||
final code = _readCode();
|
final code = _readCode();
|
||||||
if (code.length == _kOtpLength && !_autoSubmitted && _otpRequestId != null) {
|
if (code.length == _kOtpLength && !_autoSubmitted && _otpRequestId != null) {
|
||||||
_autoSubmitted = true;
|
_autoSubmitted = true;
|
||||||
|
// ignore: discarded_futures
|
||||||
|
ref.read(analyticsProvider).logAuthOtpSubmit();
|
||||||
ref.read(authProvider.notifier).verifyOtp(_otpRequestId!, code);
|
ref.read(authProvider.notifier).verifyOtp(_otpRequestId!, code);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import '../../../core/analytics/analytics_service.dart';
|
||||||
import '../../../core/auth/auth_notifier.dart';
|
import '../../../core/auth/auth_notifier.dart';
|
||||||
import '../../../core/constants.dart';
|
import '../../../core/constants.dart';
|
||||||
import '../../../core/theme/halo_tokens.dart';
|
import '../../../core/theme/halo_tokens.dart';
|
||||||
@@ -222,8 +223,15 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
|||||||
: 'kirim kode',
|
: 'kirim kode',
|
||||||
fullWidth: true,
|
fullWidth: true,
|
||||||
onPressed: canSubmit
|
onPressed: canSubmit
|
||||||
? () =>
|
? () {
|
||||||
ref.read(authProvider.notifier).requestOtp(_e164Phone())
|
// ignore: discarded_futures
|
||||||
|
ref
|
||||||
|
.read(analyticsProvider)
|
||||||
|
.logAuthStart(AnalyticsAuthMethod.phone);
|
||||||
|
ref
|
||||||
|
.read(authProvider.notifier)
|
||||||
|
.requestOtp(_e164Phone());
|
||||||
|
}
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
if (!fromProfile) ...[
|
if (!fromProfile) ...[
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import '../../../core/analytics/analytics_service.dart';
|
||||||
import '../../../core/chat/active_session_notifier.dart';
|
import '../../../core/chat/active_session_notifier.dart';
|
||||||
import '../../../core/pairing/pairing_notifier.dart';
|
import '../../../core/pairing/pairing_notifier.dart';
|
||||||
import '../../../core/theme/halo_tokens.dart';
|
import '../../../core/theme/halo_tokens.dart';
|
||||||
|
import '../../payment/state/payment_draft_provider.dart';
|
||||||
import '../../../core/theme/widgets/widgets.dart';
|
import '../../../core/theme/widgets/widgets.dart';
|
||||||
|
|
||||||
/// Phase 4 Stage 5 — S9 Match-found screen.
|
/// Phase 4 Stage 5 — S9 Match-found screen.
|
||||||
@@ -35,6 +37,15 @@ class _BestieFoundScreenState extends ConsumerState<BestieFoundScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
// Funnel step 12 — paired with a mitra. A targeted-mitra draft means the
|
||||||
|
// repeat funnel; otherwise activation. Fire once on view.
|
||||||
|
final isRepeat =
|
||||||
|
ref.read(paymentDraftNotifierProvider).targetedMitraId != null;
|
||||||
|
// ignore: discarded_futures
|
||||||
|
ref.read(analyticsProvider).logPairingMatched(
|
||||||
|
funnel:
|
||||||
|
isRepeat ? AnalyticsFunnel.repeat : AnalyticsFunnel.activation,
|
||||||
|
);
|
||||||
ref.listenManual<PairingData>(pairingProvider, (prev, next) {
|
ref.listenManual<PairingData>(pairingProvider, (prev, next) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (next is PairingActiveData) {
|
if (next is PairingActiveData) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import '../../../core/analytics/analytics_service.dart';
|
||||||
import '../../../core/chat/active_session_notifier.dart';
|
import '../../../core/chat/active_session_notifier.dart';
|
||||||
import '../../../core/chat/chat_notifier.dart';
|
import '../../../core/chat/chat_notifier.dart';
|
||||||
import '../../../core/chat/session_closure_notifier.dart';
|
import '../../../core/chat/session_closure_notifier.dart';
|
||||||
@@ -512,6 +513,8 @@ class _TimerBanner extends ConsumerWidget {
|
|||||||
return _BannerKind.none;
|
return _BannerKind.none;
|
||||||
}));
|
}));
|
||||||
void onExtend() {
|
void onExtend() {
|
||||||
|
// ignore: discarded_futures
|
||||||
|
ref.read(analyticsProvider).logExtensionOfferView(sessionId: sessionId);
|
||||||
PricingBottomSheet.showForExtension(
|
PricingBottomSheet.showForExtension(
|
||||||
context,
|
context,
|
||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import '../../../core/analytics/analytics_service.dart';
|
||||||
import '../../../core/pairing/pairing_notifier.dart';
|
import '../../../core/pairing/pairing_notifier.dart';
|
||||||
|
import '../../payment/state/payment_draft_provider.dart';
|
||||||
|
|
||||||
/// Terminal failed-pairing screen.
|
/// Terminal failed-pairing screen.
|
||||||
///
|
///
|
||||||
@@ -13,11 +15,30 @@ import '../../../core/pairing/pairing_notifier.dart';
|
|||||||
/// Single CTA "Kembali ke beranda" resets the pairing notifier and routes
|
/// Single CTA "Kembali ke beranda" resets the pairing notifier and routes
|
||||||
/// home. PopScope falls back to home for deep-link entry per project memory
|
/// home. PopScope falls back to home for deep-link entry per project memory
|
||||||
/// rule "Deep-link pop fallback".
|
/// rule "Deep-link pop fallback".
|
||||||
class NoBestieScreen extends ConsumerWidget {
|
class NoBestieScreen extends ConsumerStatefulWidget {
|
||||||
const NoBestieScreen({super.key});
|
const NoBestieScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<NoBestieScreen> createState() => _NoBestieScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NoBestieScreenState extends ConsumerState<NoBestieScreen> {
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// Funnel drop-off marker — pairing failed. A targeted-mitra draft means
|
||||||
|
// the repeat funnel; otherwise activation. Fire once on view.
|
||||||
|
final isRepeat =
|
||||||
|
ref.read(paymentDraftNotifierProvider).targetedMitraId != null;
|
||||||
|
// ignore: discarded_futures
|
||||||
|
ref.read(analyticsProvider).logPairingNoBestie(
|
||||||
|
funnel:
|
||||||
|
isRepeat ? AnalyticsFunnel.repeat : AnalyticsFunnel.activation,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
return PopScope(
|
return PopScope(
|
||||||
canPop: true,
|
canPop: true,
|
||||||
onPopInvokedWithResult: (didPop, _) {
|
onPopInvokedWithResult: (didPop, _) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../../../core/analytics/analytics_service.dart';
|
||||||
import '../../../core/chat/chat_opening_provider.dart';
|
import '../../../core/chat/chat_opening_provider.dart';
|
||||||
import '../../../core/chat/session_closure_notifier.dart';
|
import '../../../core/chat/session_closure_notifier.dart';
|
||||||
import '../../../core/constants.dart';
|
import '../../../core/constants.dart';
|
||||||
@@ -85,6 +86,10 @@ class _PricingBottomSheetState extends ConsumerState<PricingBottomSheet> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onConfirm(PriceTier tier) {
|
void _onConfirm(PriceTier tier) {
|
||||||
|
// ignore: discarded_futures
|
||||||
|
ref.read(analyticsProvider).logChatExtensionRequested(
|
||||||
|
sessionId: widget.extensionSessionId,
|
||||||
|
);
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
ref.read(sessionClosureProvider.notifier).requestExtension(
|
ref.read(sessionClosureProvider.notifier).requestExtension(
|
||||||
widget.extensionSessionId,
|
widget.extensionSessionId,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import '../../core/analytics/analytics_service.dart';
|
||||||
import '../../core/auth/auth_notifier.dart';
|
import '../../core/auth/auth_notifier.dart';
|
||||||
import '../../core/auth/onboarding_intent_provider.dart';
|
import '../../core/auth/onboarding_intent_provider.dart';
|
||||||
import '../../core/availability/mitra_availability_notifier.dart';
|
import '../../core/availability/mitra_availability_notifier.dart';
|
||||||
@@ -81,6 +82,11 @@ class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObse
|
|||||||
/// CTA path for SHomeReturning. Returning users get the bestie-choice sheet
|
/// CTA path for SHomeReturning. Returning users get the bestie-choice sheet
|
||||||
/// when they have prior history, otherwise jump to the new-payment shell.
|
/// when they have prior history, otherwise jump to the new-payment shell.
|
||||||
Future<void> _onCurhatBestieBaruPressed(BuildContext context) async {
|
Future<void> _onCurhatBestieBaruPressed(BuildContext context) async {
|
||||||
|
// Returning user starting a fresh curhat (repeat funnel). The
|
||||||
|
// bestie_reselect sub-event fires later from the history list if they pick
|
||||||
|
// a known bestie; this marks the top of the repeat funnel.
|
||||||
|
// ignore: discarded_futures
|
||||||
|
ref.read(analyticsProvider).logCurhatRepeatStart();
|
||||||
bool hasHistory;
|
bool hasHistory;
|
||||||
try {
|
try {
|
||||||
hasHistory = await ref.read(bestieHistoryHasItemsProvider.future);
|
hasHistory = await ref.read(bestieHistoryHasItemsProvider.future);
|
||||||
@@ -89,6 +95,8 @@ class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObse
|
|||||||
}
|
}
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
if (hasHistory) {
|
if (hasHistory) {
|
||||||
|
// ignore: discarded_futures
|
||||||
|
ref.read(analyticsProvider).logBestieChoiceView();
|
||||||
await BestieChoiceSheet.show(context);
|
await BestieChoiceSheet.show(context);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -101,6 +109,9 @@ class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObse
|
|||||||
/// (the call-sign check); display_name_screen kicks off `loginAnonymous`
|
/// (the call-sign check); display_name_screen kicks off `loginAnonymous`
|
||||||
/// and pushes into the verif-choice sheet.
|
/// and pushes into the verif-choice sheet.
|
||||||
void _onAkuMauCurhatPressed(BuildContext context) {
|
void _onAkuMauCurhatPressed(BuildContext context) {
|
||||||
|
// Top of the activation funnel — fresh user tapping the primary CTA.
|
||||||
|
// ignore: discarded_futures
|
||||||
|
ref.read(analyticsProvider).logCurhatStart(entryPoint: 'home_primary');
|
||||||
context.push('/auth/display-name');
|
context.push('/auth/display-name');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,7 +183,7 @@ class _SHome1stView extends ConsumerWidget {
|
|||||||
const _GreetingSubtitle(),
|
const _GreetingSubtitle(),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
_PrimaryCTA(
|
_PrimaryCTA(
|
||||||
label: 'aku mau curhat',
|
label: 'Aku Mau Curhat',
|
||||||
enabled: mitraAvailable,
|
enabled: mitraAvailable,
|
||||||
onPressed: onCTA,
|
onPressed: onCTA,
|
||||||
),
|
),
|
||||||
@@ -395,7 +406,7 @@ class _SHomeReturningView extends ConsumerWidget {
|
|||||||
activeSessionAsync.when(
|
activeSessionAsync.when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (_, __) => _PrimaryCTA(
|
error: (_, __) => _PrimaryCTA(
|
||||||
label: 'curhat sama bestie baru',
|
label: 'Aku Mau Curhat',
|
||||||
enabled: mitraAvailable,
|
enabled: mitraAvailable,
|
||||||
onPressed: onCTA,
|
onPressed: onCTA,
|
||||||
),
|
),
|
||||||
@@ -415,7 +426,7 @@ class _SHomeReturningView extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return _PrimaryCTA(
|
return _PrimaryCTA(
|
||||||
label: 'curhat sama bestie baru',
|
label: 'Aku Mau Curhat',
|
||||||
enabled: mitraAvailable,
|
enabled: mitraAvailable,
|
||||||
onPressed: onCTA,
|
onPressed: onCTA,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import '../../../core/analytics/analytics_service.dart';
|
||||||
import '../../../core/constants.dart';
|
import '../../../core/constants.dart';
|
||||||
import '../../../core/theme/halo_tokens.dart';
|
import '../../../core/theme/halo_tokens.dart';
|
||||||
import '../../../core/theme/widgets/widgets.dart';
|
import '../../../core/theme/widgets/widgets.dart';
|
||||||
@@ -132,6 +133,13 @@ class BestieHistoryListScreen extends ConsumerWidget {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (item.mitraId == null) return;
|
if (item.mitraId == null) return;
|
||||||
|
// Repeat funnel: user re-selected a known bestie. mitra_ref
|
||||||
|
// is opaque (hashed) — never the raw mitra id, per the
|
||||||
|
// no-PII / opaque-mitra-identity rule.
|
||||||
|
// ignore: discarded_futures
|
||||||
|
ref.read(analyticsProvider).logBestieReselect(
|
||||||
|
mitraRef: item.mitraId!.hashCode.toString(),
|
||||||
|
);
|
||||||
// Stamp the targeted mitra onto the payment draft; the
|
// Stamp the targeted mitra onto the payment draft; the
|
||||||
// multi-screen payment flow (entry → method → waiting →
|
// multi-screen payment flow (entry → method → waiting →
|
||||||
// notif-gate → searching) reads it back to fire the
|
// notif-gate → searching) reads it back to fire the
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import '../../../core/analytics/analytics_service.dart';
|
||||||
import '../../../core/theme/halo_tokens.dart';
|
import '../../../core/theme/halo_tokens.dart';
|
||||||
import '../../../core/theme/widgets/widgets.dart';
|
import '../../../core/theme/widgets/widgets.dart';
|
||||||
import '../../payment/state/payment_draft_provider.dart';
|
import '../../payment/state/payment_draft_provider.dart';
|
||||||
@@ -52,6 +53,8 @@ class BestieChoiceSheet extends ConsumerWidget {
|
|||||||
subtitle: 'lanjut cerita ke bestie yang pernah dengerin kamu.',
|
subtitle: 'lanjut cerita ke bestie yang pernah dengerin kamu.',
|
||||||
icon: Icons.favorite_outline,
|
icon: Icons.favorite_outline,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
// ignore: discarded_futures
|
||||||
|
ref.read(analyticsProvider).logBestieChoiceSelect(knownBestie: true);
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
context.push('/bestie/history');
|
context.push('/bestie/history');
|
||||||
},
|
},
|
||||||
@@ -62,6 +65,8 @@ class BestieChoiceSheet extends ConsumerWidget {
|
|||||||
subtitle: 'cari bestie baru yang siap dengerin sekarang.',
|
subtitle: 'cari bestie baru yang siap dengerin sekarang.',
|
||||||
icon: Icons.auto_awesome_outlined,
|
icon: Icons.auto_awesome_outlined,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
// ignore: discarded_futures
|
||||||
|
ref.read(analyticsProvider).logBestieChoiceSelect(knownBestie: false);
|
||||||
// explicit reset — this branch is blast-only, clear any stale targeted mitra
|
// explicit reset — this branch is blast-only, clear any stale targeted mitra
|
||||||
ref.read(paymentDraftNotifierProvider.notifier).reset();
|
ref.read(paymentDraftNotifierProvider.notifier).reset();
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import '../../../core/analytics/analytics_service.dart';
|
||||||
import '../../../core/theme/halo_tokens.dart';
|
import '../../../core/theme/halo_tokens.dart';
|
||||||
import '../../../core/theme/widgets/widgets.dart';
|
import '../../../core/theme/widgets/widgets.dart';
|
||||||
import '../usp_seen_provider.dart';
|
import '../usp_seen_provider.dart';
|
||||||
@@ -11,11 +12,30 @@ import '../usp_seen_provider.dart';
|
|||||||
///
|
///
|
||||||
/// `verified` ➞ USP → OTP (`/auth/register`).
|
/// `verified` ➞ USP → OTP (`/auth/register`).
|
||||||
/// `anonymous` ➞ USP → `/payment/method-pick`.
|
/// `anonymous` ➞ USP → `/payment/method-pick`.
|
||||||
class UspScreen extends ConsumerWidget {
|
class UspScreen extends ConsumerStatefulWidget {
|
||||||
final bool verified;
|
final bool verified;
|
||||||
|
|
||||||
const UspScreen({super.key, required this.verified});
|
const UspScreen({super.key, required this.verified});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<UspScreen> createState() => _UspScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _UspScreenState extends ConsumerState<UspScreen> {
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// Activation funnel step 7 — fire on view (not teardown). One-shot:
|
||||||
|
// initState runs once per screen instance.
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
// ignore: discarded_futures
|
||||||
|
ref
|
||||||
|
.read(analyticsProvider)
|
||||||
|
.logOnboardingUspView(verified: widget.verified);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
static const _cards = [
|
static const _cards = [
|
||||||
_UspCard(
|
_UspCard(
|
||||||
icon: Icons.bolt_outlined,
|
icon: Icons.bolt_outlined,
|
||||||
@@ -40,7 +60,7 @@ class UspScreen extends ConsumerWidget {
|
|||||||
];
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Padding(
|
title: const Padding(
|
||||||
@@ -94,7 +114,7 @@ class UspScreen extends ConsumerWidget {
|
|||||||
HaloButton(
|
HaloButton(
|
||||||
label: 'aku ngerti, lanjut',
|
label: 'aku ngerti, lanjut',
|
||||||
fullWidth: true,
|
fullWidth: true,
|
||||||
onPressed: () => _onContinue(context, ref),
|
onPressed: () => _onContinue(context),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -103,12 +123,12 @@ class UspScreen extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onContinue(BuildContext context, WidgetRef ref) async {
|
Future<void> _onContinue(BuildContext context) async {
|
||||||
// Persist the local + server flag before leaving — next time the user
|
// Persist the local + server flag before leaving — next time the user
|
||||||
// hits VerifChoice, this screen is skipped.
|
// hits VerifChoice, this screen is skipped.
|
||||||
await ref.read(uspSeenProvider.notifier).markSeen();
|
await ref.read(uspSeenProvider.notifier).markSeen();
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
if (verified) {
|
if (widget.verified) {
|
||||||
context.push('/auth/register');
|
context.push('/auth/register');
|
||||||
} else {
|
} else {
|
||||||
context.push('/payment/method-pick');
|
context.push('/payment/method-pick');
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import '../../../core/analytics/analytics_service.dart';
|
||||||
import '../../../core/auth/onboarding_intent_provider.dart';
|
import '../../../core/auth/onboarding_intent_provider.dart';
|
||||||
import '../../../core/chat/chat_opening_provider.dart';
|
import '../../../core/chat/chat_opening_provider.dart';
|
||||||
import '../../../core/theme/halo_tokens.dart';
|
import '../../../core/theme/halo_tokens.dart';
|
||||||
@@ -38,6 +39,18 @@ class _PaymentEntryScreenState extends ConsumerState<PaymentEntryScreen> {
|
|||||||
// inherit a stale onboarding intent.
|
// inherit a stale onboarding intent.
|
||||||
ref.read(onboardingIntentProvider.notifier).state =
|
ref.read(onboardingIntentProvider.notifier).state =
|
||||||
OnboardingIntent.recover;
|
OnboardingIntent.recover;
|
||||||
|
|
||||||
|
// Funnel step 8 — payment entry. A targeted mitra (set just before this
|
||||||
|
// screen by the bestie-history list) marks the repeat funnel; otherwise
|
||||||
|
// it's activation. resetExceptTarget() above preserves that flag.
|
||||||
|
final isRepeat =
|
||||||
|
ref.read(paymentDraftNotifierProvider).targetedMitraId != null;
|
||||||
|
// ignore: discarded_futures
|
||||||
|
ref.read(analyticsProvider).logPaymentView(
|
||||||
|
funnel:
|
||||||
|
isRepeat ? AnalyticsFunnel.repeat : AnalyticsFunnel.activation,
|
||||||
|
isRepeat: isRepeat,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:dio/dio.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import '../../../core/analytics/analytics_service.dart';
|
||||||
import '../../../core/api/api_client_provider.dart';
|
import '../../../core/api/api_client_provider.dart';
|
||||||
import '../../../core/constants.dart';
|
import '../../../core/constants.dart';
|
||||||
import '../../../core/theme/halo_tokens.dart';
|
import '../../../core/theme/halo_tokens.dart';
|
||||||
@@ -53,7 +54,21 @@ class _PaymentMethodScreenState extends ConsumerState<PaymentMethodScreen> {
|
|||||||
_error = null;
|
_error = null;
|
||||||
});
|
});
|
||||||
final api = ref.read(apiClientProvider);
|
final api = ref.read(apiClientProvider);
|
||||||
|
final analytics = ref.read(analyticsProvider);
|
||||||
try {
|
try {
|
||||||
|
// ⭐ Capture GA4 stitching identifiers BEFORE the POST so the backend can
|
||||||
|
// store them in product_metadata and replay them in the server-fired
|
||||||
|
// payment_confirmed (Measurement Protocol). The backend currently
|
||||||
|
// ignores unknown body fields — intentional; we send now, stitch later.
|
||||||
|
final appInstanceId = await analytics.appInstanceId();
|
||||||
|
final gaSessionId = await analytics.sessionId();
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final analyticsIds = <String, dynamic>{
|
||||||
|
if (appInstanceId != null) 'app_instance_id': appInstanceId,
|
||||||
|
if (gaSessionId != null) 'ga_session_id': gaSessionId,
|
||||||
|
};
|
||||||
|
|
||||||
final body = <String, dynamic>{
|
final body = <String, dynamic>{
|
||||||
'mode': draft.mode.value,
|
'mode': draft.mode.value,
|
||||||
'duration_minutes': draft.durationMinutes,
|
'duration_minutes': draft.durationMinutes,
|
||||||
@@ -61,11 +76,27 @@ class _PaymentMethodScreenState extends ConsumerState<PaymentMethodScreen> {
|
|||||||
'is_first_session_discount': draft.isFirstSessionDiscount,
|
'is_first_session_discount': draft.isFirstSessionDiscount,
|
||||||
'method': _selectedCode,
|
'method': _selectedCode,
|
||||||
if (draft.targetedMitraId != null) 'targeted_mitra_id': draft.targetedMitraId,
|
if (draft.targetedMitraId != null) 'targeted_mitra_id': draft.targetedMitraId,
|
||||||
|
if (analyticsIds.isNotEmpty) 'analytics': analyticsIds,
|
||||||
};
|
};
|
||||||
final response = await api.post('/api/client/payment-requests/', data: body);
|
final response = await api.post('/api/client/payment-requests/', data: body);
|
||||||
final data = response['data'] as Map<String, dynamic>;
|
final data = response['data'] as Map<String, dynamic>;
|
||||||
final paymentId = data['id'] as String;
|
final paymentId = data['id'] as String;
|
||||||
ref.read(paymentDraftNotifierProvider.notifier).setPaymentId(paymentId);
|
ref.read(paymentDraftNotifierProvider.notifier).setPaymentId(paymentId);
|
||||||
|
|
||||||
|
// ⭐ payment_started fires AFTER the id is known. A targeted mitra means
|
||||||
|
// the returning/repeat funnel; otherwise activation.
|
||||||
|
final isRepeat = draft.targetedMitraId != null;
|
||||||
|
// ignore: discarded_futures
|
||||||
|
analytics.logPaymentStarted(
|
||||||
|
paymentRequestId: paymentId,
|
||||||
|
amount: draft.priceIDR!,
|
||||||
|
method: _selectedCode!,
|
||||||
|
funnel: isRepeat ? AnalyticsFunnel.repeat : AnalyticsFunnel.activation,
|
||||||
|
isRepeat: isRepeat,
|
||||||
|
productType: draft.mode.value,
|
||||||
|
durationMinutes: draft.durationMinutes!,
|
||||||
|
);
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
context.push('/payment/waiting/$paymentId');
|
context.push('/payment/waiting/$paymentId');
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
@@ -216,10 +247,20 @@ class _PaymentMethodScreenState extends ConsumerState<PaymentMethodScreen> {
|
|||||||
_expandedGroupIds.remove(g.id);
|
_expandedGroupIds.remove(g.id);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
onSelect: (code) => setState(() {
|
onSelect: (code) {
|
||||||
|
// Funnel step 9 — method chosen. Fire once per pick
|
||||||
|
// (not on every rebuild).
|
||||||
|
if (code != _selectedCode) {
|
||||||
|
// ignore: discarded_futures
|
||||||
|
ref
|
||||||
|
.read(analyticsProvider)
|
||||||
|
.logPaymentMethodSelect(method: code);
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
_selectedCode = code;
|
_selectedCode = code;
|
||||||
_error = null;
|
_error = null;
|
||||||
}),
|
});
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ part of 'payment_draft_provider.dart';
|
|||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
String _$paymentDraftNotifierHash() =>
|
String _$paymentDraftNotifierHash() =>
|
||||||
r'1c81b22f25f525cd290f54618bee0b69de792998';
|
r'e489a593f5e1cc2794d13566a9cf960bb89e45c6';
|
||||||
|
|
||||||
/// See also [PaymentDraftNotifier].
|
/// See also [PaymentDraftNotifier].
|
||||||
@ProviderFor(PaymentDraftNotifier)
|
@ProviderFor(PaymentDraftNotifier)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import '../../core/auth/auth_notifier.dart';
|
import '../../core/auth/auth_notifier.dart';
|
||||||
import '../../core/theme/halo_tokens.dart';
|
import '../../core/theme/halo_tokens.dart';
|
||||||
|
import '../../core/widgets/web_page_screen.dart';
|
||||||
import '../home/widgets/halo_tab_bar.dart';
|
import '../home/widgets/halo_tab_bar.dart';
|
||||||
|
|
||||||
/// "Kamu" tab — profile screen.
|
/// "Kamu" tab — profile screen.
|
||||||
@@ -78,7 +79,14 @@ class ProfileScreen extends ConsumerWidget {
|
|||||||
_MenuItemData(
|
_MenuItemData(
|
||||||
icon: Icons.lock_outline,
|
icon: Icons.lock_outline,
|
||||||
label: 'kebijakan privasi',
|
label: 'kebijakan privasi',
|
||||||
onTap: () {},
|
onTap: () => Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => const WebPageScreen(
|
||||||
|
url: 'https://mybestieindonesia.com/privacy',
|
||||||
|
title: 'kebijakan privasi',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|||||||
@@ -1,23 +1,18 @@
|
|||||||
// File generated by FlutterFire CLI.
|
// File generated by FlutterFire CLI (regenerated from the registered
|
||||||
|
// dev Firebase apps — project halobestie-clone-dev).
|
||||||
// ignore_for_file: type=lint
|
// ignore_for_file: type=lint
|
||||||
import 'package:firebase_core/firebase_core.dart' show FirebaseOptions;
|
import 'package:firebase_core/firebase_core.dart' show FirebaseOptions;
|
||||||
import 'package:flutter/foundation.dart'
|
import 'package:flutter/foundation.dart'
|
||||||
show defaultTargetPlatform, kIsWeb, TargetPlatform;
|
show defaultTargetPlatform, kIsWeb, TargetPlatform;
|
||||||
|
|
||||||
/// Default [FirebaseOptions] for use with your Firebase apps.
|
/// [FirebaseOptions] for the DEV environment (project halobestie-clone-dev).
|
||||||
///
|
class DevFirebaseOptions {
|
||||||
/// Example:
|
|
||||||
/// ```dart
|
|
||||||
/// import 'firebase_options.dart';
|
|
||||||
/// // ...
|
|
||||||
/// await Firebase.initializeApp(
|
|
||||||
/// options: DefaultFirebaseOptions.currentPlatform,
|
|
||||||
/// );
|
|
||||||
/// ```
|
|
||||||
class DefaultFirebaseOptions {
|
|
||||||
static FirebaseOptions get currentPlatform {
|
static FirebaseOptions get currentPlatform {
|
||||||
if (kIsWeb) {
|
if (kIsWeb) {
|
||||||
return web;
|
throw UnsupportedError(
|
||||||
|
'DefaultFirebaseOptions have not been configured for web - '
|
||||||
|
'you can reconfigure this by running the FlutterFire CLI again.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
switch (defaultTargetPlatform) {
|
switch (defaultTargetPlatform) {
|
||||||
case TargetPlatform.android:
|
case TargetPlatform.android:
|
||||||
@@ -48,7 +43,7 @@ class DefaultFirebaseOptions {
|
|||||||
|
|
||||||
static const FirebaseOptions android = FirebaseOptions(
|
static const FirebaseOptions android = FirebaseOptions(
|
||||||
apiKey: 'AIzaSyDFWlLSWytqwI7LSdUbVrO7J5De9L2LV2U',
|
apiKey: 'AIzaSyDFWlLSWytqwI7LSdUbVrO7J5De9L2LV2U',
|
||||||
appId: '1:1068156046511:android:f30784f6b0423131b8185a',
|
appId: '1:1068156046511:android:1f589ed358ccdad0b8185a',
|
||||||
messagingSenderId: '1068156046511',
|
messagingSenderId: '1068156046511',
|
||||||
projectId: 'halobestie-clone-dev',
|
projectId: 'halobestie-clone-dev',
|
||||||
storageBucket: 'halobestie-clone-dev.firebasestorage.app',
|
storageBucket: 'halobestie-clone-dev.firebasestorage.app',
|
||||||
@@ -56,21 +51,10 @@ class DefaultFirebaseOptions {
|
|||||||
|
|
||||||
static const FirebaseOptions ios = FirebaseOptions(
|
static const FirebaseOptions ios = FirebaseOptions(
|
||||||
apiKey: 'AIzaSyAQnB5hbj0T5tE4JQZQ9Tx6Whp_u15obMI',
|
apiKey: 'AIzaSyAQnB5hbj0T5tE4JQZQ9Tx6Whp_u15obMI',
|
||||||
appId: '1:1068156046511:ios:b781f67a57d6db7bb8185a',
|
appId: '1:1068156046511:ios:bc9098ffc2c2913ab8185a',
|
||||||
messagingSenderId: '1068156046511',
|
messagingSenderId: '1068156046511',
|
||||||
projectId: 'halobestie-clone-dev',
|
projectId: 'halobestie-clone-dev',
|
||||||
storageBucket: 'halobestie-clone-dev.firebasestorage.app',
|
storageBucket: 'halobestie-clone-dev.firebasestorage.app',
|
||||||
iosBundleId: 'com.mybestie.mitra',
|
iosBundleId: 'com.asc.hallobestie.dev',
|
||||||
);
|
);
|
||||||
|
|
||||||
static const FirebaseOptions web = FirebaseOptions(
|
|
||||||
apiKey: 'AIzaSyAvDQp6xLOZHSwhaj9Zk3DjcMvQyX0Y7Oc',
|
|
||||||
appId: '1:1068156046511:web:15b173b38aa563ceb8185a',
|
|
||||||
messagingSenderId: '1068156046511',
|
|
||||||
projectId: 'halobestie-clone-dev',
|
|
||||||
authDomain: 'halobestie-clone-dev.firebaseapp.com',
|
|
||||||
storageBucket: 'halobestie-clone-dev.firebasestorage.app',
|
|
||||||
measurementId: 'G-FK3V0LB3TT',
|
|
||||||
);
|
|
||||||
|
|
||||||
}
|
}
|
||||||
61
client_app/lib/firebase/firebase_options_prod.dart
Normal file
61
client_app/lib/firebase/firebase_options_prod.dart
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
// File generated by FlutterFire CLI (regenerated from the registered
|
||||||
|
// prod Firebase apps — project my-bestie-production).
|
||||||
|
// ignore_for_file: type=lint
|
||||||
|
import 'package:firebase_core/firebase_core.dart' show FirebaseOptions;
|
||||||
|
import 'package:flutter/foundation.dart'
|
||||||
|
show defaultTargetPlatform, kIsWeb, TargetPlatform;
|
||||||
|
|
||||||
|
/// [FirebaseOptions] for the PROD environment (project my-bestie-production).
|
||||||
|
class ProdFirebaseOptions {
|
||||||
|
static FirebaseOptions get currentPlatform {
|
||||||
|
if (kIsWeb) {
|
||||||
|
throw UnsupportedError(
|
||||||
|
'DefaultFirebaseOptions have not been configured for web - '
|
||||||
|
'you can reconfigure this by running the FlutterFire CLI again.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
switch (defaultTargetPlatform) {
|
||||||
|
case TargetPlatform.android:
|
||||||
|
return android;
|
||||||
|
case TargetPlatform.iOS:
|
||||||
|
return ios;
|
||||||
|
case TargetPlatform.macOS:
|
||||||
|
throw UnsupportedError(
|
||||||
|
'DefaultFirebaseOptions have not been configured for macos - '
|
||||||
|
'you can reconfigure this by running the FlutterFire CLI again.',
|
||||||
|
);
|
||||||
|
case TargetPlatform.windows:
|
||||||
|
throw UnsupportedError(
|
||||||
|
'DefaultFirebaseOptions have not been configured for windows - '
|
||||||
|
'you can reconfigure this by running the FlutterFire CLI again.',
|
||||||
|
);
|
||||||
|
case TargetPlatform.linux:
|
||||||
|
throw UnsupportedError(
|
||||||
|
'DefaultFirebaseOptions have not been configured for linux - '
|
||||||
|
'you can reconfigure this by running the FlutterFire CLI again.',
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
throw UnsupportedError(
|
||||||
|
'DefaultFirebaseOptions are not supported for this platform.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static const FirebaseOptions android = FirebaseOptions(
|
||||||
|
apiKey: 'AIzaSyAxAp8hcXO-P7HwwVsS3vFe0OX5ZkIyyWI',
|
||||||
|
appId: '1:953866659887:android:55dfbf97ac7c26e7183eda',
|
||||||
|
messagingSenderId: '953866659887',
|
||||||
|
projectId: 'my-bestie-production',
|
||||||
|
storageBucket: 'my-bestie-production.firebasestorage.app',
|
||||||
|
);
|
||||||
|
|
||||||
|
static const FirebaseOptions ios = FirebaseOptions(
|
||||||
|
apiKey: 'AIzaSyA8guPSD87eDLeCsH6jVd1n2_SI4_MaGNE',
|
||||||
|
appId: '1:953866659887:ios:159fd11b1d2f3633183eda',
|
||||||
|
messagingSenderId: '953866659887',
|
||||||
|
projectId: 'my-bestie-production',
|
||||||
|
storageBucket: 'my-bestie-production.firebasestorage.app',
|
||||||
|
iosClientId: '953866659887-bsb3c2a6ir10u47q8vcacre2tmnk59jb.apps.googleusercontent.com',
|
||||||
|
iosBundleId: 'com.asc.hallobestie',
|
||||||
|
);
|
||||||
|
}
|
||||||
61
client_app/lib/firebase/firebase_options_staging.dart
Normal file
61
client_app/lib/firebase/firebase_options_staging.dart
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
// File generated by FlutterFire CLI (regenerated from the registered
|
||||||
|
// staging Firebase apps — project my-bestie-876ec).
|
||||||
|
// ignore_for_file: type=lint
|
||||||
|
import 'package:firebase_core/firebase_core.dart' show FirebaseOptions;
|
||||||
|
import 'package:flutter/foundation.dart'
|
||||||
|
show defaultTargetPlatform, kIsWeb, TargetPlatform;
|
||||||
|
|
||||||
|
/// [FirebaseOptions] for the STAGING environment (project my-bestie-876ec).
|
||||||
|
class StagingFirebaseOptions {
|
||||||
|
static FirebaseOptions get currentPlatform {
|
||||||
|
if (kIsWeb) {
|
||||||
|
throw UnsupportedError(
|
||||||
|
'DefaultFirebaseOptions have not been configured for web - '
|
||||||
|
'you can reconfigure this by running the FlutterFire CLI again.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
switch (defaultTargetPlatform) {
|
||||||
|
case TargetPlatform.android:
|
||||||
|
return android;
|
||||||
|
case TargetPlatform.iOS:
|
||||||
|
return ios;
|
||||||
|
case TargetPlatform.macOS:
|
||||||
|
throw UnsupportedError(
|
||||||
|
'DefaultFirebaseOptions have not been configured for macos - '
|
||||||
|
'you can reconfigure this by running the FlutterFire CLI again.',
|
||||||
|
);
|
||||||
|
case TargetPlatform.windows:
|
||||||
|
throw UnsupportedError(
|
||||||
|
'DefaultFirebaseOptions have not been configured for windows - '
|
||||||
|
'you can reconfigure this by running the FlutterFire CLI again.',
|
||||||
|
);
|
||||||
|
case TargetPlatform.linux:
|
||||||
|
throw UnsupportedError(
|
||||||
|
'DefaultFirebaseOptions have not been configured for linux - '
|
||||||
|
'you can reconfigure this by running the FlutterFire CLI again.',
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
throw UnsupportedError(
|
||||||
|
'DefaultFirebaseOptions are not supported for this platform.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static const FirebaseOptions android = FirebaseOptions(
|
||||||
|
apiKey: 'AIzaSyAOPkPJkHXLFzo9ICOHyjee2Vn_EUqt1Pc',
|
||||||
|
appId: '1:650461407929:android:05754df9552e0529504968',
|
||||||
|
messagingSenderId: '650461407929',
|
||||||
|
projectId: 'my-bestie-876ec',
|
||||||
|
storageBucket: 'my-bestie-876ec.firebasestorage.app',
|
||||||
|
);
|
||||||
|
|
||||||
|
static const FirebaseOptions ios = FirebaseOptions(
|
||||||
|
apiKey: 'AIzaSyC_BewS88iaNsc9QdwsPzkV0sf9wUs4i_4',
|
||||||
|
appId: '1:650461407929:ios:4ee79d479b69d688504968',
|
||||||
|
messagingSenderId: '650461407929',
|
||||||
|
projectId: 'my-bestie-876ec',
|
||||||
|
storageBucket: 'my-bestie-876ec.firebasestorage.app',
|
||||||
|
iosClientId: '650461407929-fb4t48nmguaslfvis7ebsea8vndro7ac.apps.googleusercontent.com',
|
||||||
|
iosBundleId: 'com.asc.hallobestie.staging',
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:firebase_analytics/firebase_analytics.dart';
|
||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'core/analytics/analytics_service.dart';
|
||||||
import 'core/api/api_client_provider.dart';
|
import 'core/api/api_client_provider.dart';
|
||||||
import 'core/auth/auth_notifier.dart';
|
import 'core/auth/auth_notifier.dart';
|
||||||
import 'core/auth/auth_providers_provider.dart';
|
import 'core/auth/auth_providers_provider.dart';
|
||||||
@@ -13,10 +15,23 @@ import 'core/chat/chat_notifier.dart';
|
|||||||
import 'core/notifications/notification_service.dart';
|
import 'core/notifications/notification_service.dart';
|
||||||
import 'core/pairing/pairing_notifier.dart';
|
import 'core/pairing/pairing_notifier.dart';
|
||||||
import 'core/theme/halo_theme.dart';
|
import 'core/theme/halo_theme.dart';
|
||||||
import 'firebase_options.dart';
|
import 'firebase/firebase_options_dev.dart';
|
||||||
import 'router.dart';
|
import 'router.dart';
|
||||||
|
|
||||||
void main() async {
|
/// Shared app bootstrap, parameterised per build flavor.
|
||||||
|
///
|
||||||
|
/// The flavor entrypoints (`main_dev.dart`, `main_staging.dart`,
|
||||||
|
/// `main_prod.dart`) each call this with their environment's
|
||||||
|
/// [FirebaseOptions] and a [flavor] tag. The bare [main] below delegates to
|
||||||
|
/// dev so a plain `flutter run` (no `-t`) still launches the dev environment.
|
||||||
|
///
|
||||||
|
/// `flavor` is currently informational (kept on hand for future flavor-gated
|
||||||
|
/// behaviour / analytics tagging); the API base URL is supplied separately via
|
||||||
|
/// `--dart-define-from-file=env/<flavor>.json` (see BUILD_FLAVORS.md).
|
||||||
|
Future<void> bootstrap({
|
||||||
|
required FirebaseOptions firebaseOptions,
|
||||||
|
required String flavor,
|
||||||
|
}) async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
// Pre-warm flutter_secure_storage. The first call triggers AndroidX
|
// Pre-warm flutter_secure_storage. The first call triggers AndroidX
|
||||||
@@ -26,7 +41,13 @@ void main() async {
|
|||||||
// splash instead of paying it on the user's first interaction.
|
// splash instead of paying it on the user's first interaction.
|
||||||
unawaited(TokenStorage().readRefreshToken());
|
unawaited(TokenStorage().readRefreshToken());
|
||||||
|
|
||||||
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
|
await Firebase.initializeApp(options: firebaseOptions);
|
||||||
|
|
||||||
|
// Enable GA4 collection. Fire-and-forget so it never adds to cold-start
|
||||||
|
// latency; the SDK queues events until collection is on.
|
||||||
|
unawaited(
|
||||||
|
FirebaseAnalytics.instance.setAnalyticsCollectionEnabled(true),
|
||||||
|
);
|
||||||
|
|
||||||
final messaging = FirebaseMessaging.instance;
|
final messaging = FirebaseMessaging.instance;
|
||||||
await messaging.requestPermission();
|
await messaging.requestPermission();
|
||||||
@@ -34,6 +55,16 @@ void main() async {
|
|||||||
runApp(const ProviderScope(child: App()));
|
runApp(const ProviderScope(child: App()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void main() async {
|
||||||
|
// Bare `flutter run` (no `-t lib/main_<flavor>.dart`) defaults to dev so
|
||||||
|
// local development works out of the box. Build-flavor APKs use the
|
||||||
|
// flavor-specific entrypoints instead.
|
||||||
|
await bootstrap(
|
||||||
|
firebaseOptions: DevFirebaseOptions.currentPlatform,
|
||||||
|
flavor: 'dev',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
class App extends ConsumerStatefulWidget {
|
class App extends ConsumerStatefulWidget {
|
||||||
const App({super.key});
|
const App({super.key});
|
||||||
|
|
||||||
@@ -131,9 +162,41 @@ class _AppState extends ConsumerState<App> with WidgetsBindingObserver {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tracks the last user_id pushed to GA4 so we don't re-issue identical
|
||||||
|
// setUserId/user-property calls on every transient auth emission.
|
||||||
|
String? _analyticsUserId;
|
||||||
|
|
||||||
|
/// Mirror auth state into GA4 identity (§4): opaque customer UUID as
|
||||||
|
/// `user_id` + `user_type` property. Re-set on identity upgrade
|
||||||
|
/// (anon→verified) so the same user continues. Never sets phone/name.
|
||||||
|
void _syncAnalyticsIdentity(AuthData? data) {
|
||||||
|
final analytics = ref.read(analyticsProvider);
|
||||||
|
final (String? customerId, AnalyticsUserType? userType) = switch (data) {
|
||||||
|
AuthAnonymousData d => (d.customerId, AnalyticsUserType.anonymous),
|
||||||
|
AuthForceRegisterData d => (d.customerId, AnalyticsUserType.anonymous),
|
||||||
|
AuthAuthenticatedData d => (
|
||||||
|
d.profile['id'] as String?,
|
||||||
|
AnalyticsUserType.verified,
|
||||||
|
),
|
||||||
|
AuthNeedsDisplayNameData d => (
|
||||||
|
d.profile['id'] as String?,
|
||||||
|
AnalyticsUserType.verified,
|
||||||
|
),
|
||||||
|
_ => (null, null),
|
||||||
|
};
|
||||||
|
if (customerId == _analyticsUserId) return;
|
||||||
|
_analyticsUserId = customerId;
|
||||||
|
// ignore: discarded_futures
|
||||||
|
analytics.setUserId(customerId);
|
||||||
|
if (userType != null) {
|
||||||
|
// ignore: discarded_futures
|
||||||
|
analytics.setUserType(userType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// FCM registration on auth.
|
// FCM registration + analytics identity on auth.
|
||||||
ref.listen(authProvider, (prev, next) {
|
ref.listen(authProvider, (prev, next) {
|
||||||
final data = next.valueOrNull;
|
final data = next.valueOrNull;
|
||||||
if (data is AuthAuthenticatedData || data is AuthAnonymousData) {
|
if (data is AuthAuthenticatedData || data is AuthAnonymousData) {
|
||||||
@@ -142,6 +205,7 @@ class _AppState extends ConsumerState<App> with WidgetsBindingObserver {
|
|||||||
// Logged out (or initial) — ensure the chat WS is closed.
|
// Logged out (or initial) — ensure the chat WS is closed.
|
||||||
ref.read(chatProvider.notifier).disconnect();
|
ref.read(chatProvider.notifier).disconnect();
|
||||||
}
|
}
|
||||||
|
_syncAnalyticsIdentity(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Global chat WebSocket lifecycle: connect whenever the user has an
|
// Global chat WebSocket lifecycle: connect whenever the user has an
|
||||||
|
|||||||
14
client_app/lib/main_dev.dart
Normal file
14
client_app/lib/main_dev.dart
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import 'firebase/firebase_options_dev.dart';
|
||||||
|
import 'main.dart';
|
||||||
|
|
||||||
|
/// DEV flavor entrypoint.
|
||||||
|
///
|
||||||
|
/// Run/build with the matching flavor + env file:
|
||||||
|
/// flutter run --flavor dev -t lib/main_dev.dart \
|
||||||
|
/// --dart-define-from-file=env/dev.json
|
||||||
|
void main() {
|
||||||
|
bootstrap(
|
||||||
|
firebaseOptions: DevFirebaseOptions.currentPlatform,
|
||||||
|
flavor: 'dev',
|
||||||
|
);
|
||||||
|
}
|
||||||
14
client_app/lib/main_prod.dart
Normal file
14
client_app/lib/main_prod.dart
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import 'firebase/firebase_options_prod.dart';
|
||||||
|
import 'main.dart';
|
||||||
|
|
||||||
|
/// PROD flavor entrypoint.
|
||||||
|
///
|
||||||
|
/// Run/build with the matching flavor + env file:
|
||||||
|
/// flutter build apk --flavor prod -t lib/main_prod.dart \
|
||||||
|
/// --dart-define-from-file=env/prod.json
|
||||||
|
void main() {
|
||||||
|
bootstrap(
|
||||||
|
firebaseOptions: ProdFirebaseOptions.currentPlatform,
|
||||||
|
flavor: 'prod',
|
||||||
|
);
|
||||||
|
}
|
||||||
14
client_app/lib/main_staging.dart
Normal file
14
client_app/lib/main_staging.dart
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import 'firebase/firebase_options_staging.dart';
|
||||||
|
import 'main.dart';
|
||||||
|
|
||||||
|
/// STAGING flavor entrypoint.
|
||||||
|
///
|
||||||
|
/// Run/build with the matching flavor + env file:
|
||||||
|
/// flutter run --flavor staging -t lib/main_staging.dart \
|
||||||
|
/// --dart-define-from-file=env/staging.json
|
||||||
|
void main() {
|
||||||
|
bootstrap(
|
||||||
|
firebaseOptions: StagingFirebaseOptions.currentPlatform,
|
||||||
|
flavor: 'staging',
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import 'package:firebase_analytics/firebase_analytics.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'core/analytics/analytics_service.dart';
|
||||||
import 'core/auth/auth_notifier.dart';
|
import 'core/auth/auth_notifier.dart';
|
||||||
import 'core/auth/onboarding_intent_provider.dart';
|
import 'core/auth/onboarding_intent_provider.dart';
|
||||||
import 'features/auth/screens/display_name_screen.dart';
|
import 'features/auth/screens/display_name_screen.dart';
|
||||||
@@ -54,12 +56,62 @@ class RouterNotifier extends ChangeNotifier {
|
|||||||
|
|
||||||
final routerProvider = Provider<GoRouter>((ref) => buildRouter(ref));
|
final routerProvider = Provider<GoRouter>((ref) => buildRouter(ref));
|
||||||
|
|
||||||
|
/// Maps a GoRoute path template (`Route.settings.name`) to a stable
|
||||||
|
/// `screen_name`. Keyed on the *template* (e.g. `/chat/session/:sessionId`)
|
||||||
|
/// so path params are never part of the logged name. Routes absent here are
|
||||||
|
/// dropped (return null → observer skips the screen_view).
|
||||||
|
const _screenNameByRoute = <String, AnalyticsScreen>{
|
||||||
|
'/splash': AnalyticsScreen.splash,
|
||||||
|
'/auth/display-name': AnalyticsScreen.authDisplayName,
|
||||||
|
'/auth/register': AnalyticsScreen.authRegister,
|
||||||
|
'/auth/otp': AnalyticsScreen.authOtp,
|
||||||
|
'/auth/set-name': AnalyticsScreen.authSetName,
|
||||||
|
'/auth/force-register': AnalyticsScreen.authForceRegister,
|
||||||
|
'/onboarding/verif/usp': AnalyticsScreen.onboardingUspVerified,
|
||||||
|
'/onboarding/anon/usp': AnalyticsScreen.onboardingUspAnon,
|
||||||
|
'/onboarding/notif-gate': AnalyticsScreen.onboardingNotifGate,
|
||||||
|
'/home': AnalyticsScreen.home,
|
||||||
|
'/profile': AnalyticsScreen.profile,
|
||||||
|
'/payment/entry': AnalyticsScreen.paymentEntry,
|
||||||
|
'/payment/discount-paywall': AnalyticsScreen.paymentDiscountPaywall,
|
||||||
|
'/payment/method-pick': AnalyticsScreen.curhatModePick,
|
||||||
|
'/payment/duration-pick': AnalyticsScreen.paymentDurationPick,
|
||||||
|
'/payment/method': AnalyticsScreen.paymentMethod,
|
||||||
|
'/payment/waiting/:paymentId': AnalyticsScreen.paymentWaiting,
|
||||||
|
'/payment/expired/:paymentId': AnalyticsScreen.paymentExpired,
|
||||||
|
'/chat/searching': AnalyticsScreen.chatSearching,
|
||||||
|
'/chat/found': AnalyticsScreen.chatFound,
|
||||||
|
'/chat/no-bestie': AnalyticsScreen.chatNoBestie,
|
||||||
|
'/chat/waiting-targeted/:mitraId': AnalyticsScreen.chatWaitingTargeted,
|
||||||
|
'/chat/session/:sessionId': AnalyticsScreen.chatSession,
|
||||||
|
'/chat/thank-you': AnalyticsScreen.chatThankYou,
|
||||||
|
'/chat/aktif': AnalyticsScreen.chatTabAktif,
|
||||||
|
'/chat/pembayaran': AnalyticsScreen.chatTabPembayaran,
|
||||||
|
'/chat/selesai': AnalyticsScreen.chatTabSelesai,
|
||||||
|
'/chat/transcript/:sessionId': AnalyticsScreen.chatTranscript,
|
||||||
|
'/bestie/history': AnalyticsScreen.bestieHistory,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// `nameExtractor` for [FirebaseAnalyticsObserver]. GoRouter sets
|
||||||
|
/// `Route.settings.name` to the route's path template, so this strips path
|
||||||
|
/// params (`:sessionId` etc.) by construction.
|
||||||
|
String? _screenNameFor(RouteSettings settings) {
|
||||||
|
final name = settings.name;
|
||||||
|
if (name == null) return null;
|
||||||
|
return _screenNameByRoute[name]?.value;
|
||||||
|
}
|
||||||
|
|
||||||
GoRouter buildRouter(Ref ref) {
|
GoRouter buildRouter(Ref ref) {
|
||||||
final notifier = RouterNotifier(ref);
|
final notifier = RouterNotifier(ref);
|
||||||
|
final analyticsObserver = FirebaseAnalyticsObserver(
|
||||||
|
analytics: FirebaseAnalytics.instance,
|
||||||
|
nameExtractor: _screenNameFor,
|
||||||
|
);
|
||||||
|
|
||||||
return GoRouter(
|
return GoRouter(
|
||||||
initialLocation: kThemePreviewEnabled ? '/_theme_preview' : '/splash',
|
initialLocation: kThemePreviewEnabled ? '/_theme_preview' : '/splash',
|
||||||
refreshListenable: notifier,
|
refreshListenable: notifier,
|
||||||
|
observers: [analyticsObserver],
|
||||||
redirect: (context, state) {
|
redirect: (context, state) {
|
||||||
// Theme preview is dev-only and intentionally bypasses auth + onboarding
|
// Theme preview is dev-only and intentionally bypasses auth + onboarding
|
||||||
// gates so it can be opened on any device build.
|
// gates so it can be opened on any device build.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import FlutterMacOS
|
import FlutterMacOS
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
import firebase_analytics
|
||||||
import firebase_core
|
import firebase_core
|
||||||
import firebase_messaging
|
import firebase_messaging
|
||||||
import flutter_local_notifications
|
import flutter_local_notifications
|
||||||
@@ -17,6 +18,7 @@ import url_launcher_macos
|
|||||||
import webview_flutter_wkwebview
|
import webview_flutter_wkwebview
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
|
FirebaseAnalyticsPlugin.register(with: registry.registrar(forPlugin: "FirebaseAnalyticsPlugin"))
|
||||||
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
||||||
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
|
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
|
||||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||||
|
|||||||
@@ -297,6 +297,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.1"
|
version: "7.0.1"
|
||||||
|
firebase_analytics:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: firebase_analytics
|
||||||
|
sha256: "4f85b161772e1d54a66893ef131c0a44bd9e552efa78b33d5f4f60d2caa5c8a3"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "11.6.0"
|
||||||
|
firebase_analytics_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: firebase_analytics_platform_interface
|
||||||
|
sha256: a44b6d1155ed5cae7641e3de7163111cfd9f6f6c954ca916dc6a3bdfa86bf845
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.4.3"
|
||||||
|
firebase_analytics_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: firebase_analytics_web
|
||||||
|
sha256: c7d1ed1f86ae64215757518af5576ff88341c8ce5741988c05cc3b2e07b0b273
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.5.10+16"
|
||||||
firebase_core:
|
firebase_core:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ dependencies:
|
|||||||
# Firebase (Messaging only — Auth dropped in Phase 3.4, self-managed JWT now)
|
# Firebase (Messaging only — Auth dropped in Phase 3.4, self-managed JWT now)
|
||||||
firebase_core: ^3.12.1
|
firebase_core: ^3.12.1
|
||||||
firebase_messaging: ^15.2.5
|
firebase_messaging: ^15.2.5
|
||||||
|
# GA4 funnel analytics (no PII — see requirement/analytics-funnel-plan.md).
|
||||||
|
# ^11.x is the firebase_core ^3.x-compatible major.
|
||||||
|
firebase_analytics: ^11.4.4
|
||||||
|
|
||||||
# Social login (kept — buttons gated server-side via /api/shared/auth-providers
|
# Social login (kept — buttons gated server-side via /api/shared/auth-providers
|
||||||
# until the corresponding OAuth env vars are set on the backend)
|
# until the corresponding OAuth env vars are set on the backend)
|
||||||
|
|||||||
121
mitra_app/BUILD_FLAVORS.md
Normal file
121
mitra_app/BUILD_FLAVORS.md
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
# Build Flavors — mitra_app (Android)
|
||||||
|
|
||||||
|
The mitra_app has three Android build flavors: **dev**, **staging**, **prod**.
|
||||||
|
Each has its own `applicationId`, backend URL, app display name, Dart entrypoint,
|
||||||
|
Firebase Dart options, and `google-services.json` source set — so all three can
|
||||||
|
be installed side-by-side on one device.
|
||||||
|
|
||||||
|
> Scope note: this is **Android + Dart + env-files** only. iOS Xcode schemes are
|
||||||
|
> a separate follow-up and are NOT set up yet.
|
||||||
|
|
||||||
|
## Flavor matrix
|
||||||
|
|
||||||
|
| Flavor | applicationId | API_BASE_URL | App name | Entrypoint | env file |
|
||||||
|
|---------|-----------------------------|-------------------------------------------|---------------------------|-----------------------|-------------------|
|
||||||
|
| dev | `com.mybestie.mitra.dev` | `http://192.168.88.247:3000` | Mitra HaloBestie Dev | `lib/main_dev.dart` | `env/dev.json` |
|
||||||
|
| staging | `com.mybestie.mitra.staging`| `https://staging-api.halobestie.com` ⚠️ | Mitra HaloBestie Staging | `lib/main_staging.dart`| `env/staging.json`|
|
||||||
|
| prod | `com.mybestie.mitra` | `https://api.halobestie.com` | Mitra HaloBestie | `lib/main_prod.dart` | `env/prod.json` |
|
||||||
|
|
||||||
|
⚠️ The staging `API_BASE_URL` is a **placeholder** — confirm the real staging
|
||||||
|
host and update `env/staging.json` + `lib/firebase/firebase_options_staging.dart`.
|
||||||
|
|
||||||
|
The `applicationId` suffix is applied in `android/app/build.gradle.kts`
|
||||||
|
(`applicationIdSuffix = ".dev"` / `".staging"`; prod has none). The app name is
|
||||||
|
emitted per flavor via `resValue("string", "app_name", "...")` and read by
|
||||||
|
`android/app/src/main/AndroidManifest.xml` through `android:label="@string/app_name"`.
|
||||||
|
|
||||||
|
## Build / run commands
|
||||||
|
|
||||||
|
Every command MUST pass `--flavor`, a matching `-t` entrypoint, and
|
||||||
|
`--dart-define-from-file` for the env. Examples:
|
||||||
|
|
||||||
|
### Run (debug, on a device/emulator)
|
||||||
|
```bash
|
||||||
|
flutter run --flavor dev -t lib/main_dev.dart --dart-define-from-file=env/dev.json
|
||||||
|
flutter run --flavor staging -t lib/main_staging.dart --dart-define-from-file=env/staging.json
|
||||||
|
flutter run --flavor prod -t lib/main_prod.dart --dart-define-from-file=env/prod.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build APK
|
||||||
|
```bash
|
||||||
|
flutter build apk --flavor dev -t lib/main_dev.dart --dart-define-from-file=env/dev.json
|
||||||
|
flutter build apk --flavor staging -t lib/main_staging.dart --dart-define-from-file=env/staging.json
|
||||||
|
flutter build apk --flavor prod -t lib/main_prod.dart --dart-define-from-file=env/prod.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build App Bundle (Play Store)
|
||||||
|
```bash
|
||||||
|
flutter build appbundle --flavor prod -t lib/main_prod.dart --dart-define-from-file=env/prod.json
|
||||||
|
```
|
||||||
|
|
||||||
|
A bare `flutter run` (no `-t`) still works — `lib/main.dart` delegates to the
|
||||||
|
dev bootstrap — but it builds with no flavor selected on Android, so prefer the
|
||||||
|
explicit commands above.
|
||||||
|
|
||||||
|
## ⚠️ CRITICAL warnings
|
||||||
|
|
||||||
|
1. **`--flavor` is now mandatory for builds.** Once product flavors exist, a
|
||||||
|
bare `flutter build apk` (without `--flavor`) FAILS with a Gradle error
|
||||||
|
(no default flavor). Every build/run command must specify `--flavor` and the
|
||||||
|
matching `-t lib/main_<flavor>.dart` entrypoint.
|
||||||
|
|
||||||
|
2. **The dev applicationId changed to `com.mybestie.mitra.dev`.** Any tooling
|
||||||
|
that references the old package id must be updated when running the dev
|
||||||
|
flavor:
|
||||||
|
- `adb` commands: `adb shell pm clear com.mybestie.mitra.dev`,
|
||||||
|
`adb shell am start ... com.mybestie.mitra.dev/...`, etc.
|
||||||
|
- Maestro flows: `appId: com.mybestie.mitra.dev`.
|
||||||
|
- Any deeplink / FCM tooling keyed on the package name.
|
||||||
|
Prod keeps `com.mybestie.mitra`; staging is `com.mybestie.mitra.staging`.
|
||||||
|
|
||||||
|
## Firebase config — STATUS: configured ✅ (2026-06-04)
|
||||||
|
|
||||||
|
Firebase init is **Dart-side** (`Firebase.initializeApp(options:)` in
|
||||||
|
`lib/bootstrap.dart`), driven by the per-flavor
|
||||||
|
`lib/firebase/firebase_options_<flavor>.dart`. The mitra app is a **brand-new
|
||||||
|
app** on both platforms (no legacy App Store identity), so the iOS bundle base
|
||||||
|
is `com.mybestie.mitra` — unlike the customer app, which inherits
|
||||||
|
`com.asc.hallobestie`.
|
||||||
|
|
||||||
|
All apps registered + config in place across 3 projects (one per env):
|
||||||
|
|
||||||
|
| Env | Firebase project | Android applicationId | iOS bundle ID |
|
||||||
|
|---------|------------------------|-------------------------------|------------------------------|
|
||||||
|
| dev | `halobestie-clone-dev` | `com.mybestie.mitra.dev` | `com.mybestie.mitra.dev` |
|
||||||
|
| staging | `my-bestie-876ec` | `com.mybestie.mitra.staging` | `com.mybestie.mitra.staging` |
|
||||||
|
| prod | `my-bestie-production` | `com.mybestie.mitra` | `com.mybestie.mitra` |
|
||||||
|
|
||||||
|
In place and verified:
|
||||||
|
- `android/app/src/<flavor>/google-services.json` — all 3, client matches the flavor applicationId.
|
||||||
|
- `ios/config/<flavor>/GoogleService-Info.plist` — all 3, bundle IDs verified.
|
||||||
|
- `lib/firebase/firebase_options_{dev,staging,prod}.dart` — real android + iOS values, no placeholders.
|
||||||
|
|
||||||
|
### Regenerating after any ID / key change
|
||||||
|
```bash
|
||||||
|
flutterfire configure --project=halobestie-clone-dev --out=lib/firebase/firebase_options_dev.dart
|
||||||
|
flutterfire configure --project=my-bestie-876ec --out=lib/firebase/firebase_options_staging.dart
|
||||||
|
flutterfire configure --project=my-bestie-production --out=lib/firebase/firebase_options_prod.dart
|
||||||
|
```
|
||||||
|
|
||||||
|
### Still TODO — iOS only (Mac/Xcode)
|
||||||
|
- [ ] iOS Xcode schemes + build-phase copy script to select the right
|
||||||
|
`GoogleService-Info.plist` per flavor (until then iOS bundles only
|
||||||
|
`ios/Runner/GoogleService-Info.plist`). See `ios/config/README.md`.
|
||||||
|
|
||||||
|
> The Google Services Gradle plugin is **not** applied in this app — Android
|
||||||
|
> Firebase init is Dart-side. The `src/<flavor>/google-services.json` files are
|
||||||
|
> laid out for if/when that plugin is added.
|
||||||
|
|
||||||
|
## File map
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `android/app/build.gradle.kts` | `flavorDimensions "env"` + `productFlavors` (id suffix + app_name) |
|
||||||
|
| `android/app/src/main/AndroidManifest.xml` | `android:label="@string/app_name"` |
|
||||||
|
| `android/app/src/dev/google-services.json` | dev Firebase config |
|
||||||
|
| `android/app/src/{staging,prod}/google-services.json.README` | placeholders — drop real json here |
|
||||||
|
| `lib/bootstrap.dart` | shared `bootstrap()` + `App` widget |
|
||||||
|
| `lib/main.dart` | bare entrypoint → delegates to dev |
|
||||||
|
| `lib/main_{dev,staging,prod}.dart` | per-flavor entrypoints |
|
||||||
|
| `lib/firebase/firebase_options_{dev,staging,prod}.dart` | per-flavor Dart Firebase options |
|
||||||
|
| `env/{dev,staging,prod}.json` | dart-define values (`API_BASE_URL`, `FLAVOR`) |
|
||||||
@@ -21,7 +21,8 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
// Base Application ID. Per-flavor suffixes are applied below in
|
||||||
|
// productFlavors (dev → .dev, staging → .staging, prod → no suffix).
|
||||||
applicationId = "com.mybestie.mitra"
|
applicationId = "com.mybestie.mitra"
|
||||||
// You can update the following values to match your application needs.
|
// You can update the following values to match your application needs.
|
||||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||||
@@ -31,6 +32,30 @@ android {
|
|||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build flavors: dev / staging / prod. Each gets its own applicationId
|
||||||
|
// (so all three can be installed side-by-side) and its own app_name string
|
||||||
|
// resource (consumed by AndroidManifest's android:label="@string/app_name").
|
||||||
|
// A bare `flutter build`/`flutter run` WITHOUT --flavor now fails — every
|
||||||
|
// command must pass --flavor and the matching -t entrypoint.
|
||||||
|
flavorDimensions += "env"
|
||||||
|
productFlavors {
|
||||||
|
create("dev") {
|
||||||
|
dimension = "env"
|
||||||
|
applicationIdSuffix = ".dev"
|
||||||
|
resValue("string", "app_name", "Mitra HaloBestie Dev")
|
||||||
|
}
|
||||||
|
create("staging") {
|
||||||
|
dimension = "env"
|
||||||
|
applicationIdSuffix = ".staging"
|
||||||
|
resValue("string", "app_name", "Mitra HaloBestie Staging")
|
||||||
|
}
|
||||||
|
create("prod") {
|
||||||
|
dimension = "env"
|
||||||
|
// No applicationIdSuffix — prod keeps the base com.mybestie.mitra.
|
||||||
|
resValue("string", "app_name", "Mitra HaloBestie")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
// TODO: Add your own signing config for the release build.
|
// TODO: Add your own signing config for the release build.
|
||||||
|
|||||||
@@ -61,6 +61,63 @@
|
|||||||
"other_platform_oauth_client": []
|
"other_platform_oauth_client": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"client_info": {
|
||||||
|
"mobilesdk_app_id": "1:1068156046511:android:1f589ed358ccdad0b8185a",
|
||||||
|
"android_client_info": {
|
||||||
|
"package_name": "com.mybestie.dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oauth_client": [],
|
||||||
|
"api_key": [
|
||||||
|
{
|
||||||
|
"current_key": "AIzaSyDFWlLSWytqwI7LSdUbVrO7J5De9L2LV2U"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": {
|
||||||
|
"appinvite_service": {
|
||||||
|
"other_platform_oauth_client": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"client_info": {
|
||||||
|
"mobilesdk_app_id": "1:1068156046511:android:8a873c8b7e64410ab8185a",
|
||||||
|
"android_client_info": {
|
||||||
|
"package_name": "com.mybestie.mitra"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oauth_client": [],
|
||||||
|
"api_key": [
|
||||||
|
{
|
||||||
|
"current_key": "AIzaSyDFWlLSWytqwI7LSdUbVrO7J5De9L2LV2U"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": {
|
||||||
|
"appinvite_service": {
|
||||||
|
"other_platform_oauth_client": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"client_info": {
|
||||||
|
"mobilesdk_app_id": "1:1068156046511:android:f527c763dea3dc36b8185a",
|
||||||
|
"android_client_info": {
|
||||||
|
"package_name": "com.mybestie.mitra.dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oauth_client": [],
|
||||||
|
"api_key": [
|
||||||
|
{
|
||||||
|
"current_key": "AIzaSyDFWlLSWytqwI7LSdUbVrO7J5De9L2LV2U"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": {
|
||||||
|
"appinvite_service": {
|
||||||
|
"other_platform_oauth_client": []
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"configuration_version": "1"
|
"configuration_version": "1"
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<application
|
<application
|
||||||
android:label="Mitra HaloBestie"
|
android:label="@string/app_name"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:usesCleartextTraffic="true">
|
android:usesCleartextTraffic="true">
|
||||||
|
|||||||
106
mitra_app/android/app/src/prod/google-services.json
Executable file
106
mitra_app/android/app/src/prod/google-services.json
Executable file
@@ -0,0 +1,106 @@
|
|||||||
|
{
|
||||||
|
"project_info": {
|
||||||
|
"project_number": "953866659887",
|
||||||
|
"project_id": "my-bestie-production",
|
||||||
|
"storage_bucket": "my-bestie-production.firebasestorage.app"
|
||||||
|
},
|
||||||
|
"client": [
|
||||||
|
{
|
||||||
|
"client_info": {
|
||||||
|
"mobilesdk_app_id": "1:953866659887:android:55dfbf97ac7c26e7183eda",
|
||||||
|
"android_client_info": {
|
||||||
|
"package_name": "com.mybestie"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oauth_client": [
|
||||||
|
{
|
||||||
|
"client_id": "953866659887-5a62u0tdce92i0gmfo0gf3dt0dnlre42.apps.googleusercontent.com",
|
||||||
|
"client_type": 1,
|
||||||
|
"android_info": {
|
||||||
|
"package_name": "com.mybestie",
|
||||||
|
"certificate_hash": "3c43db3c9ac7f6d7e2fa03b8dbcaf7e5d12c97f3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"client_id": "953866659887-kebg3eijcomtv97q6v03fm8i30kj7r9r.apps.googleusercontent.com",
|
||||||
|
"client_type": 1,
|
||||||
|
"android_info": {
|
||||||
|
"package_name": "com.mybestie",
|
||||||
|
"certificate_hash": "7119b6cf7091074759450c899191905a5a4d0369"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"client_id": "953866659887-vvur2mnmbu8ljmnmmg01hqsrj0ocssu9.apps.googleusercontent.com",
|
||||||
|
"client_type": 1,
|
||||||
|
"android_info": {
|
||||||
|
"package_name": "com.mybestie",
|
||||||
|
"certificate_hash": "6e23f0164af04ddf200f769c460caac3ee2b91ac"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"client_id": "953866659887-9ro36er68qupv00rgokjignnu2hs85v8.apps.googleusercontent.com",
|
||||||
|
"client_type": 3
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"api_key": [
|
||||||
|
{
|
||||||
|
"current_key": "AIzaSyAxAp8hcXO-P7HwwVsS3vFe0OX5ZkIyyWI"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": {
|
||||||
|
"appinvite_service": {
|
||||||
|
"other_platform_oauth_client": [
|
||||||
|
{
|
||||||
|
"client_id": "953866659887-9ro36er68qupv00rgokjignnu2hs85v8.apps.googleusercontent.com",
|
||||||
|
"client_type": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"client_id": "953866659887-bsb3c2a6ir10u47q8vcacre2tmnk59jb.apps.googleusercontent.com",
|
||||||
|
"client_type": 2,
|
||||||
|
"ios_info": {
|
||||||
|
"bundle_id": "com.asc.hallobestie"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"client_info": {
|
||||||
|
"mobilesdk_app_id": "1:953866659887:android:a4b99d675b0b0315183eda",
|
||||||
|
"android_client_info": {
|
||||||
|
"package_name": "com.mybestie.mitra"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oauth_client": [
|
||||||
|
{
|
||||||
|
"client_id": "953866659887-9ro36er68qupv00rgokjignnu2hs85v8.apps.googleusercontent.com",
|
||||||
|
"client_type": 3
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"api_key": [
|
||||||
|
{
|
||||||
|
"current_key": "AIzaSyAxAp8hcXO-P7HwwVsS3vFe0OX5ZkIyyWI"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": {
|
||||||
|
"appinvite_service": {
|
||||||
|
"other_platform_oauth_client": [
|
||||||
|
{
|
||||||
|
"client_id": "953866659887-9ro36er68qupv00rgokjignnu2hs85v8.apps.googleusercontent.com",
|
||||||
|
"client_type": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"client_id": "953866659887-bsb3c2a6ir10u47q8vcacre2tmnk59jb.apps.googleusercontent.com",
|
||||||
|
"client_type": 2,
|
||||||
|
"ios_info": {
|
||||||
|
"bundle_id": "com.asc.hallobestie"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"configuration_version": "1"
|
||||||
|
}
|
||||||
29
mitra_app/android/app/src/prod/google-services.json.README
Normal file
29
mitra_app/android/app/src/prod/google-services.json.README
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
PROD flavor — google-services.json goes HERE
|
||||||
|
============================================
|
||||||
|
|
||||||
|
This directory is the `prod` flavor source set. The Google Services Gradle
|
||||||
|
plugin reads `android/app/src/prod/google-services.json` when you build the
|
||||||
|
prod flavor. It is intentionally MISSING right now.
|
||||||
|
|
||||||
|
To make the prod flavor build:
|
||||||
|
|
||||||
|
1. In the Firebase Console (the PRODUCTION project), add an Android app with
|
||||||
|
package name:
|
||||||
|
|
||||||
|
com.mybestie.mitra
|
||||||
|
|
||||||
|
(prod has NO applicationIdSuffix — it keeps the base id.)
|
||||||
|
|
||||||
|
2. Download the generated `google-services.json`.
|
||||||
|
|
||||||
|
3. Drop it in this folder, replacing this README:
|
||||||
|
|
||||||
|
android/app/src/prod/google-services.json
|
||||||
|
|
||||||
|
4. Also run `flutterfire configure` for the production project/package and paste
|
||||||
|
the generated Dart values into:
|
||||||
|
|
||||||
|
lib/firebase/firebase_options_prod.dart
|
||||||
|
|
||||||
|
DO NOT copy the dev google-services.json here and edit it by hand — the
|
||||||
|
mobilesdk_app_id / api_key must come from the real Firebase registration.
|
||||||
142
mitra_app/android/app/src/staging/google-services.json
Executable file
142
mitra_app/android/app/src/staging/google-services.json
Executable file
@@ -0,0 +1,142 @@
|
|||||||
|
{
|
||||||
|
"project_info": {
|
||||||
|
"project_number": "650461407929",
|
||||||
|
"project_id": "my-bestie-876ec",
|
||||||
|
"storage_bucket": "my-bestie-876ec.firebasestorage.app"
|
||||||
|
},
|
||||||
|
"client": [
|
||||||
|
{
|
||||||
|
"client_info": {
|
||||||
|
"mobilesdk_app_id": "1:650461407929:android:92d95eb766802bcf504968",
|
||||||
|
"android_client_info": {
|
||||||
|
"package_name": "com.mybestie"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oauth_client": [
|
||||||
|
{
|
||||||
|
"client_id": "650461407929-ofuff9cv8d4fj79efj9e2ovt2nj9b83a.apps.googleusercontent.com",
|
||||||
|
"client_type": 3
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"api_key": [
|
||||||
|
{
|
||||||
|
"current_key": "AIzaSyAOPkPJkHXLFzo9ICOHyjee2Vn_EUqt1Pc"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": {
|
||||||
|
"appinvite_service": {
|
||||||
|
"other_platform_oauth_client": [
|
||||||
|
{
|
||||||
|
"client_id": "650461407929-ofuff9cv8d4fj79efj9e2ovt2nj9b83a.apps.googleusercontent.com",
|
||||||
|
"client_type": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"client_id": "650461407929-2o7eo5ts2k389pa3l16sq26l3107b52f.apps.googleusercontent.com",
|
||||||
|
"client_type": 2,
|
||||||
|
"ios_info": {
|
||||||
|
"bundle_id": "com.example.halloBestie"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"client_info": {
|
||||||
|
"mobilesdk_app_id": "1:650461407929:android:7571ae8d5036de5d504968",
|
||||||
|
"android_client_info": {
|
||||||
|
"package_name": "com.mybestie.mitra.staging"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oauth_client": [
|
||||||
|
{
|
||||||
|
"client_id": "650461407929-ofuff9cv8d4fj79efj9e2ovt2nj9b83a.apps.googleusercontent.com",
|
||||||
|
"client_type": 3
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"api_key": [
|
||||||
|
{
|
||||||
|
"current_key": "AIzaSyAOPkPJkHXLFzo9ICOHyjee2Vn_EUqt1Pc"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": {
|
||||||
|
"appinvite_service": {
|
||||||
|
"other_platform_oauth_client": [
|
||||||
|
{
|
||||||
|
"client_id": "650461407929-ofuff9cv8d4fj79efj9e2ovt2nj9b83a.apps.googleusercontent.com",
|
||||||
|
"client_type": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"client_id": "650461407929-2o7eo5ts2k389pa3l16sq26l3107b52f.apps.googleusercontent.com",
|
||||||
|
"client_type": 2,
|
||||||
|
"ios_info": {
|
||||||
|
"bundle_id": "com.example.halloBestie"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"client_info": {
|
||||||
|
"mobilesdk_app_id": "1:650461407929:android:05754df9552e0529504968",
|
||||||
|
"android_client_info": {
|
||||||
|
"package_name": "com.mybestie.staging"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oauth_client": [
|
||||||
|
{
|
||||||
|
"client_id": "650461407929-3els6l9cegtiphe930vtjr80ffa2p1uv.apps.googleusercontent.com",
|
||||||
|
"client_type": 1,
|
||||||
|
"android_info": {
|
||||||
|
"package_name": "com.mybestie.staging",
|
||||||
|
"certificate_hash": "a46c19a615b3c21b529240dabc8f1cd68bcbd449"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"client_id": "650461407929-8lo71sr668gvvj0ntpjjemoqrkr82uid.apps.googleusercontent.com",
|
||||||
|
"client_type": 1,
|
||||||
|
"android_info": {
|
||||||
|
"package_name": "com.mybestie.staging",
|
||||||
|
"certificate_hash": "6e23f0164af04ddf200f769c460caac3ee2b91ac"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"client_id": "650461407929-lmj6n5jt818fkdjhjpbhabdd19g82f48.apps.googleusercontent.com",
|
||||||
|
"client_type": 1,
|
||||||
|
"android_info": {
|
||||||
|
"package_name": "com.mybestie.staging",
|
||||||
|
"certificate_hash": "937ecfa181a695a5f1fb5d04df15e490c174caea"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"client_id": "650461407929-ofuff9cv8d4fj79efj9e2ovt2nj9b83a.apps.googleusercontent.com",
|
||||||
|
"client_type": 3
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"api_key": [
|
||||||
|
{
|
||||||
|
"current_key": "AIzaSyAOPkPJkHXLFzo9ICOHyjee2Vn_EUqt1Pc"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": {
|
||||||
|
"appinvite_service": {
|
||||||
|
"other_platform_oauth_client": [
|
||||||
|
{
|
||||||
|
"client_id": "650461407929-ofuff9cv8d4fj79efj9e2ovt2nj9b83a.apps.googleusercontent.com",
|
||||||
|
"client_type": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"client_id": "650461407929-2o7eo5ts2k389pa3l16sq26l3107b52f.apps.googleusercontent.com",
|
||||||
|
"client_type": 2,
|
||||||
|
"ios_info": {
|
||||||
|
"bundle_id": "com.example.halloBestie"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"configuration_version": "1"
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
STAGING flavor — google-services.json goes HERE
|
||||||
|
================================================
|
||||||
|
|
||||||
|
This directory is the `staging` flavor source set. The Google Services Gradle
|
||||||
|
plugin reads `android/app/src/staging/google-services.json` when you build the
|
||||||
|
staging flavor. It is intentionally MISSING right now.
|
||||||
|
|
||||||
|
To make the staging flavor build:
|
||||||
|
|
||||||
|
1. In the Firebase Console (the staging / nonprod project), add an Android app
|
||||||
|
with package name:
|
||||||
|
|
||||||
|
com.mybestie.mitra.staging
|
||||||
|
|
||||||
|
(note the `.staging` suffix — set by `applicationIdSuffix` in
|
||||||
|
android/app/build.gradle.kts).
|
||||||
|
|
||||||
|
2. Download the generated `google-services.json`.
|
||||||
|
|
||||||
|
3. Drop it in this folder, replacing this README:
|
||||||
|
|
||||||
|
android/app/src/staging/google-services.json
|
||||||
|
|
||||||
|
4. Also run `flutterfire configure` for that project/package and paste the
|
||||||
|
generated Dart values into:
|
||||||
|
|
||||||
|
lib/firebase/firebase_options_staging.dart
|
||||||
|
|
||||||
|
DO NOT copy the dev google-services.json here and edit it by hand — the
|
||||||
|
mobilesdk_app_id / api_key must come from the real Firebase registration.
|
||||||
4
mitra_app/env/dev.json
vendored
Normal file
4
mitra_app/env/dev.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"API_BASE_URL": "http://192.168.88.247:3000",
|
||||||
|
"FLAVOR": "dev"
|
||||||
|
}
|
||||||
4
mitra_app/env/prod.json
vendored
Normal file
4
mitra_app/env/prod.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"API_BASE_URL": "https://api.halobestie.com",
|
||||||
|
"FLAVOR": "prod"
|
||||||
|
}
|
||||||
4
mitra_app/env/staging.json
vendored
Normal file
4
mitra_app/env/staging.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"API_BASE_URL": "https://staging-api.halobestie.com",
|
||||||
|
"FLAVOR": "staging"
|
||||||
|
}
|
||||||
34
mitra_app/ios/config/README.md
Normal file
34
mitra_app/ios/config/README.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# iOS per-flavor Firebase config — mitra_app
|
||||||
|
|
||||||
|
iOS has **no** automatic per-flavor resolution (unlike Android's
|
||||||
|
`android/app/src/<flavor>/google-services.json`). Stage the three
|
||||||
|
`GoogleService-Info.plist` files here, one per flavor:
|
||||||
|
|
||||||
|
```
|
||||||
|
ios/config/
|
||||||
|
dev/GoogleService-Info.plist → bundle com.mybestie.mitra.dev (dev project)
|
||||||
|
staging/GoogleService-Info.plist → bundle com.mybestie.mitra.staging (staging/nonprod)
|
||||||
|
prod/GoogleService-Info.plist → bundle com.mybestie.mitra (prod project)
|
||||||
|
```
|
||||||
|
|
||||||
|
> The mitra app is a **brand-new app** on both platforms — no legacy App Store
|
||||||
|
> identity (unlike the customer app, which inherits `com.asc.hallobestie`).
|
||||||
|
|
||||||
|
## Wiring (Xcode — Mac follow-up, not done yet)
|
||||||
|
|
||||||
|
The active plist Xcode bundles is `ios/Runner/GoogleService-Info.plist`. To make
|
||||||
|
it per-flavor, add a **Run Script** build phase to the Runner target, placed
|
||||||
|
**before** "Compile Sources":
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp "${PROJECT_DIR}/config/${FLAVOR}/GoogleService-Info.plist" \
|
||||||
|
"${PROJECT_DIR}/Runner/GoogleService-Info.plist"
|
||||||
|
```
|
||||||
|
|
||||||
|
`${FLAVOR}` is a per-scheme/configuration build setting you define when creating
|
||||||
|
the dev/staging/prod schemes (e.g. `FLAVOR = dev` in the dev configuration).
|
||||||
|
|
||||||
|
### Single-env shortcut
|
||||||
|
If you only need one env working (e.g. dev) before the full scheme setup, just
|
||||||
|
drop that env's plist directly at `ios/Runner/GoogleService-Info.plist` — no
|
||||||
|
script needed for a single environment.
|
||||||
30
mitra_app/ios/config/dev/GoogleService-Info.plist
Executable file
30
mitra_app/ios/config/dev/GoogleService-Info.plist
Executable file
@@ -0,0 +1,30 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>API_KEY</key>
|
||||||
|
<string>AIzaSyAQnB5hbj0T5tE4JQZQ9Tx6Whp_u15obMI</string>
|
||||||
|
<key>GCM_SENDER_ID</key>
|
||||||
|
<string>1068156046511</string>
|
||||||
|
<key>PLIST_VERSION</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>BUNDLE_ID</key>
|
||||||
|
<string>com.mybestie.mitra.dev</string>
|
||||||
|
<key>PROJECT_ID</key>
|
||||||
|
<string>halobestie-clone-dev</string>
|
||||||
|
<key>STORAGE_BUCKET</key>
|
||||||
|
<string>halobestie-clone-dev.firebasestorage.app</string>
|
||||||
|
<key>IS_ADS_ENABLED</key>
|
||||||
|
<false></false>
|
||||||
|
<key>IS_ANALYTICS_ENABLED</key>
|
||||||
|
<false></false>
|
||||||
|
<key>IS_APPINVITE_ENABLED</key>
|
||||||
|
<true></true>
|
||||||
|
<key>IS_GCM_ENABLED</key>
|
||||||
|
<true></true>
|
||||||
|
<key>IS_SIGNIN_ENABLED</key>
|
||||||
|
<true></true>
|
||||||
|
<key>GOOGLE_APP_ID</key>
|
||||||
|
<string>1:1068156046511:ios:907b28451e22981db8185a</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
14
mitra_app/ios/config/dev/GoogleService-Info.plist.README
Normal file
14
mitra_app/ios/config/dev/GoogleService-Info.plist.README
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
Place the DEV GoogleService-Info.plist here, named exactly:
|
||||||
|
|
||||||
|
GoogleService-Info.plist
|
||||||
|
|
||||||
|
Register this iOS app first:
|
||||||
|
• Firebase project: halobestie-clone-dev (the DEV project)
|
||||||
|
• iOS bundle ID: com.mybestie.mitra.dev
|
||||||
|
Then download its GoogleService-Info.plist into this folder (delete this .README).
|
||||||
|
|
||||||
|
Also paste its appId/apiKey into lib/firebase/firebase_options_dev.dart.
|
||||||
|
|
||||||
|
How it gets used: a build-phase "Run Script" (added in Xcode — Mac follow-up)
|
||||||
|
copies the per-flavor file into ios/Runner/GoogleService-Info.plist at build
|
||||||
|
time. See ../README.md and BUILD_FLAVORS.md.
|
||||||
36
mitra_app/ios/config/prod/GoogleService-Info.plist
Executable file
36
mitra_app/ios/config/prod/GoogleService-Info.plist
Executable file
@@ -0,0 +1,36 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CLIENT_ID</key>
|
||||||
|
<string>953866659887-i0atpahlpqt6r9id17hjeirt5j1uqu8k.apps.googleusercontent.com</string>
|
||||||
|
<key>REVERSED_CLIENT_ID</key>
|
||||||
|
<string>com.googleusercontent.apps.953866659887-i0atpahlpqt6r9id17hjeirt5j1uqu8k</string>
|
||||||
|
<key>ANDROID_CLIENT_ID</key>
|
||||||
|
<string>953866659887-5a62u0tdce92i0gmfo0gf3dt0dnlre42.apps.googleusercontent.com</string>
|
||||||
|
<key>API_KEY</key>
|
||||||
|
<string>AIzaSyA8guPSD87eDLeCsH6jVd1n2_SI4_MaGNE</string>
|
||||||
|
<key>GCM_SENDER_ID</key>
|
||||||
|
<string>953866659887</string>
|
||||||
|
<key>PLIST_VERSION</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>BUNDLE_ID</key>
|
||||||
|
<string>com.mybestie.mitra</string>
|
||||||
|
<key>PROJECT_ID</key>
|
||||||
|
<string>my-bestie-production</string>
|
||||||
|
<key>STORAGE_BUCKET</key>
|
||||||
|
<string>my-bestie-production.firebasestorage.app</string>
|
||||||
|
<key>IS_ADS_ENABLED</key>
|
||||||
|
<false></false>
|
||||||
|
<key>IS_ANALYTICS_ENABLED</key>
|
||||||
|
<false></false>
|
||||||
|
<key>IS_APPINVITE_ENABLED</key>
|
||||||
|
<true></true>
|
||||||
|
<key>IS_GCM_ENABLED</key>
|
||||||
|
<true></true>
|
||||||
|
<key>IS_SIGNIN_ENABLED</key>
|
||||||
|
<true></true>
|
||||||
|
<key>GOOGLE_APP_ID</key>
|
||||||
|
<string>1:953866659887:ios:cd8dd704842f3489183eda</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
16
mitra_app/ios/config/prod/GoogleService-Info.plist.README
Normal file
16
mitra_app/ios/config/prod/GoogleService-Info.plist.README
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
Place the PROD GoogleService-Info.plist here, named exactly:
|
||||||
|
|
||||||
|
GoogleService-Info.plist
|
||||||
|
|
||||||
|
Register this iOS app first:
|
||||||
|
• Firebase project: the PRODUCTION project
|
||||||
|
• iOS bundle ID: com.mybestie.mitra (brand-new app — mitra has no legacy
|
||||||
|
App Store identity, unlike the customer app)
|
||||||
|
Then download its GoogleService-Info.plist into this folder (delete this .README).
|
||||||
|
|
||||||
|
Also paste its values into lib/firebase/firebase_options_prod.dart
|
||||||
|
(currently all PLACEHOLDER).
|
||||||
|
|
||||||
|
How it gets used: a build-phase "Run Script" (added in Xcode — Mac follow-up)
|
||||||
|
copies the per-flavor file into ios/Runner/GoogleService-Info.plist at build
|
||||||
|
time. See ../README.md and BUILD_FLAVORS.md.
|
||||||
36
mitra_app/ios/config/staging/GoogleService-Info.plist
Executable file
36
mitra_app/ios/config/staging/GoogleService-Info.plist
Executable file
@@ -0,0 +1,36 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CLIENT_ID</key>
|
||||||
|
<string>650461407929-kuc6m53nehsa677geu57f3fmn9ql6lbg.apps.googleusercontent.com</string>
|
||||||
|
<key>REVERSED_CLIENT_ID</key>
|
||||||
|
<string>com.googleusercontent.apps.650461407929-kuc6m53nehsa677geu57f3fmn9ql6lbg</string>
|
||||||
|
<key>ANDROID_CLIENT_ID</key>
|
||||||
|
<string>650461407929-3els6l9cegtiphe930vtjr80ffa2p1uv.apps.googleusercontent.com</string>
|
||||||
|
<key>API_KEY</key>
|
||||||
|
<string>AIzaSyC_BewS88iaNsc9QdwsPzkV0sf9wUs4i_4</string>
|
||||||
|
<key>GCM_SENDER_ID</key>
|
||||||
|
<string>650461407929</string>
|
||||||
|
<key>PLIST_VERSION</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>BUNDLE_ID</key>
|
||||||
|
<string>com.mybestie.mitra.staging</string>
|
||||||
|
<key>PROJECT_ID</key>
|
||||||
|
<string>my-bestie-876ec</string>
|
||||||
|
<key>STORAGE_BUCKET</key>
|
||||||
|
<string>my-bestie-876ec.firebasestorage.app</string>
|
||||||
|
<key>IS_ADS_ENABLED</key>
|
||||||
|
<false></false>
|
||||||
|
<key>IS_ANALYTICS_ENABLED</key>
|
||||||
|
<false></false>
|
||||||
|
<key>IS_APPINVITE_ENABLED</key>
|
||||||
|
<true></true>
|
||||||
|
<key>IS_GCM_ENABLED</key>
|
||||||
|
<true></true>
|
||||||
|
<key>IS_SIGNIN_ENABLED</key>
|
||||||
|
<true></true>
|
||||||
|
<key>GOOGLE_APP_ID</key>
|
||||||
|
<string>1:650461407929:ios:b273bda6ad4045ca504968</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
15
mitra_app/ios/config/staging/GoogleService-Info.plist.README
Normal file
15
mitra_app/ios/config/staging/GoogleService-Info.plist.README
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
Place the STAGING GoogleService-Info.plist here, named exactly:
|
||||||
|
|
||||||
|
GoogleService-Info.plist
|
||||||
|
|
||||||
|
Register this iOS app first:
|
||||||
|
• Firebase project: the STAGING / nonprod project
|
||||||
|
• iOS bundle ID: com.mybestie.mitra.staging
|
||||||
|
Then download its GoogleService-Info.plist into this folder (delete this .README).
|
||||||
|
|
||||||
|
Also paste its values into lib/firebase/firebase_options_staging.dart
|
||||||
|
(currently all PLACEHOLDER).
|
||||||
|
|
||||||
|
How it gets used: a build-phase "Run Script" (added in Xcode — Mac follow-up)
|
||||||
|
copies the per-flavor file into ios/Runner/GoogleService-Info.plist at build
|
||||||
|
time. See ../README.md and BUILD_FLAVORS.md.
|
||||||
125
mitra_app/lib/bootstrap.dart
Normal file
125
mitra_app/lib/bootstrap.dart
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'core/api/api_client_provider.dart';
|
||||||
|
import 'core/auth/auth_notifier.dart';
|
||||||
|
import 'core/chat/mitra_chat_notifier.dart';
|
||||||
|
import 'core/status/status_notifier.dart';
|
||||||
|
import 'core/chat/chat_request_notifier.dart';
|
||||||
|
import 'core/chat/widgets/chat_request_overlay.dart';
|
||||||
|
import 'core/notifications/notification_service.dart';
|
||||||
|
import 'core/theme/halo_theme.dart';
|
||||||
|
import 'router.dart';
|
||||||
|
|
||||||
|
/// Shared app bootstrap used by every flavor entrypoint
|
||||||
|
/// (main_dev / main_staging / main_prod). Each entrypoint passes the
|
||||||
|
/// flavor's own [FirebaseOptions] and a [flavor] tag.
|
||||||
|
Future<void> bootstrap({
|
||||||
|
required FirebaseOptions firebaseOptions,
|
||||||
|
required String flavor,
|
||||||
|
}) async {
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
await Firebase.initializeApp(options: firebaseOptions);
|
||||||
|
|
||||||
|
final messaging = FirebaseMessaging.instance;
|
||||||
|
await messaging.requestPermission();
|
||||||
|
|
||||||
|
runApp(const ProviderScope(child: App()));
|
||||||
|
}
|
||||||
|
|
||||||
|
class App extends ConsumerStatefulWidget {
|
||||||
|
const App({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<App> createState() => _AppState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AppState extends ConsumerState<App> with WidgetsBindingObserver {
|
||||||
|
bool _fcmRegistered = false;
|
||||||
|
// Session the chat WS was on at the moment we backgrounded. Restored on
|
||||||
|
// resume so a backgrounded mitra reconnects to the same chat once they
|
||||||
|
// foreground the app. Mirrors the customer-app fix (main.dart on the
|
||||||
|
// client side) — backend's sendMessage checks recipient WS readyState
|
||||||
|
// before falling back to FCM, so leaving the WS open while paused makes
|
||||||
|
// FCM never fire and the mitra misses customer messages in background.
|
||||||
|
String? _pausedChatSessionId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
if (state == AppLifecycleState.paused || state == AppLifecycleState.detached) {
|
||||||
|
ref.read(onlineStatusProvider.notifier).onAppPaused();
|
||||||
|
// Close the chat WS so backend `sendMessage` falls back to FCM when
|
||||||
|
// the customer sends a message. Stash the active session_id so we
|
||||||
|
// can rejoin it on resume.
|
||||||
|
final chatNotifier = ref.read(mitraChatProvider.notifier);
|
||||||
|
final sid = chatNotifier.connectedSessionId;
|
||||||
|
if (sid != null) {
|
||||||
|
_pausedChatSessionId = sid;
|
||||||
|
chatNotifier.disconnect();
|
||||||
|
}
|
||||||
|
} else if (state == AppLifecycleState.resumed) {
|
||||||
|
ref.read(onlineStatusProvider.notifier).onAppResumed();
|
||||||
|
// Reconnect to the chat we backgrounded out of, if any.
|
||||||
|
final saved = _pausedChatSessionId;
|
||||||
|
_pausedChatSessionId = null;
|
||||||
|
if (saved != null) {
|
||||||
|
// ignore: discarded_futures
|
||||||
|
ref.read(mitraChatProvider.notifier).connect(saved);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _registerFcmToken() {
|
||||||
|
if (_fcmRegistered) return;
|
||||||
|
_fcmRegistered = true;
|
||||||
|
Future(() async {
|
||||||
|
try {
|
||||||
|
final token = await FirebaseMessaging.instance.getToken();
|
||||||
|
if (token != null) {
|
||||||
|
await ref.read(apiClientProvider).post('/api/shared/device-token', data: {'token': token});
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
_fcmRegistered = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// Listen for auth changes to load status and register FCM
|
||||||
|
ref.listen(mitraAuthProvider, (prev, next) {
|
||||||
|
final data = next.valueOrNull;
|
||||||
|
if (data is MitraAuthAuthenticatedData) {
|
||||||
|
ref.read(onlineStatusProvider.notifier).load();
|
||||||
|
_registerFcmToken();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
final router = ref.watch(routerProvider);
|
||||||
|
NotificationService.initialize(router);
|
||||||
|
NotificationService.onChatRequestTapped = (sessionId) {
|
||||||
|
ref.read(chatRequestProvider.notifier).setIncomingFromNotification(sessionId);
|
||||||
|
};
|
||||||
|
|
||||||
|
return ChatRequestOverlay(
|
||||||
|
child: MaterialApp.router(
|
||||||
|
title: 'Halo Bestie Mitra',
|
||||||
|
theme: haloThemeData(),
|
||||||
|
routerConfig: router,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +1,11 @@
|
|||||||
// File generated by FlutterFire CLI.
|
// File generated by FlutterFire CLI (regenerated from the registered
|
||||||
|
// dev Firebase apps — project halobestie-clone-dev).
|
||||||
// ignore_for_file: type=lint
|
// ignore_for_file: type=lint
|
||||||
import 'package:firebase_core/firebase_core.dart' show FirebaseOptions;
|
import 'package:firebase_core/firebase_core.dart' show FirebaseOptions;
|
||||||
import 'package:flutter/foundation.dart'
|
import 'package:flutter/foundation.dart'
|
||||||
show defaultTargetPlatform, kIsWeb, TargetPlatform;
|
show defaultTargetPlatform, kIsWeb, TargetPlatform;
|
||||||
|
|
||||||
/// Default [FirebaseOptions] for use with your Firebase apps.
|
/// [FirebaseOptions] for the DEV environment (project halobestie-clone-dev).
|
||||||
///
|
|
||||||
/// Example:
|
|
||||||
/// ```dart
|
|
||||||
/// import 'firebase_options.dart';
|
|
||||||
/// // ...
|
|
||||||
/// await Firebase.initializeApp(
|
|
||||||
/// options: DefaultFirebaseOptions.currentPlatform,
|
|
||||||
/// );
|
|
||||||
/// ```
|
|
||||||
class DefaultFirebaseOptions {
|
class DefaultFirebaseOptions {
|
||||||
static FirebaseOptions get currentPlatform {
|
static FirebaseOptions get currentPlatform {
|
||||||
if (kIsWeb) {
|
if (kIsWeb) {
|
||||||
@@ -51,7 +43,7 @@ class DefaultFirebaseOptions {
|
|||||||
|
|
||||||
static const FirebaseOptions android = FirebaseOptions(
|
static const FirebaseOptions android = FirebaseOptions(
|
||||||
apiKey: 'AIzaSyDFWlLSWytqwI7LSdUbVrO7J5De9L2LV2U',
|
apiKey: 'AIzaSyDFWlLSWytqwI7LSdUbVrO7J5De9L2LV2U',
|
||||||
appId: '1:1068156046511:android:ba6e699216de1c50b8185a',
|
appId: '1:1068156046511:android:f527c763dea3dc36b8185a',
|
||||||
messagingSenderId: '1068156046511',
|
messagingSenderId: '1068156046511',
|
||||||
projectId: 'halobestie-clone-dev',
|
projectId: 'halobestie-clone-dev',
|
||||||
storageBucket: 'halobestie-clone-dev.firebasestorage.app',
|
storageBucket: 'halobestie-clone-dev.firebasestorage.app',
|
||||||
@@ -59,10 +51,10 @@ class DefaultFirebaseOptions {
|
|||||||
|
|
||||||
static const FirebaseOptions ios = FirebaseOptions(
|
static const FirebaseOptions ios = FirebaseOptions(
|
||||||
apiKey: 'AIzaSyAQnB5hbj0T5tE4JQZQ9Tx6Whp_u15obMI',
|
apiKey: 'AIzaSyAQnB5hbj0T5tE4JQZQ9Tx6Whp_u15obMI',
|
||||||
appId: '1:1068156046511:ios:c7786cedb9101d34b8185a',
|
appId: '1:1068156046511:ios:907b28451e22981db8185a',
|
||||||
messagingSenderId: '1068156046511',
|
messagingSenderId: '1068156046511',
|
||||||
projectId: 'halobestie-clone-dev',
|
projectId: 'halobestie-clone-dev',
|
||||||
storageBucket: 'halobestie-clone-dev.firebasestorage.app',
|
storageBucket: 'halobestie-clone-dev.firebasestorage.app',
|
||||||
iosBundleId: 'com.mybestie',
|
iosBundleId: 'com.mybestie.mitra.dev',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
61
mitra_app/lib/firebase/firebase_options_prod.dart
Normal file
61
mitra_app/lib/firebase/firebase_options_prod.dart
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
// File generated by FlutterFire CLI (regenerated from the registered
|
||||||
|
// prod Firebase apps — project my-bestie-production).
|
||||||
|
// ignore_for_file: type=lint
|
||||||
|
import 'package:firebase_core/firebase_core.dart' show FirebaseOptions;
|
||||||
|
import 'package:flutter/foundation.dart'
|
||||||
|
show defaultTargetPlatform, kIsWeb, TargetPlatform;
|
||||||
|
|
||||||
|
/// [FirebaseOptions] for the PROD environment (project my-bestie-production).
|
||||||
|
class DefaultFirebaseOptions {
|
||||||
|
static FirebaseOptions get currentPlatform {
|
||||||
|
if (kIsWeb) {
|
||||||
|
throw UnsupportedError(
|
||||||
|
'DefaultFirebaseOptions have not been configured for web - '
|
||||||
|
'you can reconfigure this by running the FlutterFire CLI again.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
switch (defaultTargetPlatform) {
|
||||||
|
case TargetPlatform.android:
|
||||||
|
return android;
|
||||||
|
case TargetPlatform.iOS:
|
||||||
|
return ios;
|
||||||
|
case TargetPlatform.macOS:
|
||||||
|
throw UnsupportedError(
|
||||||
|
'DefaultFirebaseOptions have not been configured for macos - '
|
||||||
|
'you can reconfigure this by running the FlutterFire CLI again.',
|
||||||
|
);
|
||||||
|
case TargetPlatform.windows:
|
||||||
|
throw UnsupportedError(
|
||||||
|
'DefaultFirebaseOptions have not been configured for windows - '
|
||||||
|
'you can reconfigure this by running the FlutterFire CLI again.',
|
||||||
|
);
|
||||||
|
case TargetPlatform.linux:
|
||||||
|
throw UnsupportedError(
|
||||||
|
'DefaultFirebaseOptions have not been configured for linux - '
|
||||||
|
'you can reconfigure this by running the FlutterFire CLI again.',
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
throw UnsupportedError(
|
||||||
|
'DefaultFirebaseOptions are not supported for this platform.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static const FirebaseOptions android = FirebaseOptions(
|
||||||
|
apiKey: 'AIzaSyAxAp8hcXO-P7HwwVsS3vFe0OX5ZkIyyWI',
|
||||||
|
appId: '1:953866659887:android:a4b99d675b0b0315183eda',
|
||||||
|
messagingSenderId: '953866659887',
|
||||||
|
projectId: 'my-bestie-production',
|
||||||
|
storageBucket: 'my-bestie-production.firebasestorage.app',
|
||||||
|
);
|
||||||
|
|
||||||
|
static const FirebaseOptions ios = FirebaseOptions(
|
||||||
|
apiKey: 'AIzaSyA8guPSD87eDLeCsH6jVd1n2_SI4_MaGNE',
|
||||||
|
appId: '1:953866659887:ios:cd8dd704842f3489183eda',
|
||||||
|
messagingSenderId: '953866659887',
|
||||||
|
projectId: 'my-bestie-production',
|
||||||
|
storageBucket: 'my-bestie-production.firebasestorage.app',
|
||||||
|
iosClientId: '953866659887-i0atpahlpqt6r9id17hjeirt5j1uqu8k.apps.googleusercontent.com',
|
||||||
|
iosBundleId: 'com.mybestie.mitra',
|
||||||
|
);
|
||||||
|
}
|
||||||
61
mitra_app/lib/firebase/firebase_options_staging.dart
Normal file
61
mitra_app/lib/firebase/firebase_options_staging.dart
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
// File generated by FlutterFire CLI (regenerated from the registered
|
||||||
|
// staging Firebase apps — project my-bestie-876ec).
|
||||||
|
// ignore_for_file: type=lint
|
||||||
|
import 'package:firebase_core/firebase_core.dart' show FirebaseOptions;
|
||||||
|
import 'package:flutter/foundation.dart'
|
||||||
|
show defaultTargetPlatform, kIsWeb, TargetPlatform;
|
||||||
|
|
||||||
|
/// [FirebaseOptions] for the STAGING environment (project my-bestie-876ec).
|
||||||
|
class DefaultFirebaseOptions {
|
||||||
|
static FirebaseOptions get currentPlatform {
|
||||||
|
if (kIsWeb) {
|
||||||
|
throw UnsupportedError(
|
||||||
|
'DefaultFirebaseOptions have not been configured for web - '
|
||||||
|
'you can reconfigure this by running the FlutterFire CLI again.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
switch (defaultTargetPlatform) {
|
||||||
|
case TargetPlatform.android:
|
||||||
|
return android;
|
||||||
|
case TargetPlatform.iOS:
|
||||||
|
return ios;
|
||||||
|
case TargetPlatform.macOS:
|
||||||
|
throw UnsupportedError(
|
||||||
|
'DefaultFirebaseOptions have not been configured for macos - '
|
||||||
|
'you can reconfigure this by running the FlutterFire CLI again.',
|
||||||
|
);
|
||||||
|
case TargetPlatform.windows:
|
||||||
|
throw UnsupportedError(
|
||||||
|
'DefaultFirebaseOptions have not been configured for windows - '
|
||||||
|
'you can reconfigure this by running the FlutterFire CLI again.',
|
||||||
|
);
|
||||||
|
case TargetPlatform.linux:
|
||||||
|
throw UnsupportedError(
|
||||||
|
'DefaultFirebaseOptions have not been configured for linux - '
|
||||||
|
'you can reconfigure this by running the FlutterFire CLI again.',
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
throw UnsupportedError(
|
||||||
|
'DefaultFirebaseOptions are not supported for this platform.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static const FirebaseOptions android = FirebaseOptions(
|
||||||
|
apiKey: 'AIzaSyAOPkPJkHXLFzo9ICOHyjee2Vn_EUqt1Pc',
|
||||||
|
appId: '1:650461407929:android:7571ae8d5036de5d504968',
|
||||||
|
messagingSenderId: '650461407929',
|
||||||
|
projectId: 'my-bestie-876ec',
|
||||||
|
storageBucket: 'my-bestie-876ec.firebasestorage.app',
|
||||||
|
);
|
||||||
|
|
||||||
|
static const FirebaseOptions ios = FirebaseOptions(
|
||||||
|
apiKey: 'AIzaSyC_BewS88iaNsc9QdwsPzkV0sf9wUs4i_4',
|
||||||
|
appId: '1:650461407929:ios:b273bda6ad4045ca504968',
|
||||||
|
messagingSenderId: '650461407929',
|
||||||
|
projectId: 'my-bestie-876ec',
|
||||||
|
storageBucket: 'my-bestie-876ec.firebasestorage.app',
|
||||||
|
iosClientId: '650461407929-kuc6m53nehsa677geu57f3fmn9ql6lbg.apps.googleusercontent.com',
|
||||||
|
iosBundleId: 'com.mybestie.mitra.staging',
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,120 +1,15 @@
|
|||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'bootstrap.dart';
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'firebase/firebase_options_dev.dart';
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
import 'core/api/api_client_provider.dart';
|
|
||||||
import 'core/auth/auth_notifier.dart';
|
|
||||||
import 'core/chat/mitra_chat_notifier.dart';
|
|
||||||
import 'core/status/status_notifier.dart';
|
|
||||||
import 'core/chat/chat_request_notifier.dart';
|
|
||||||
import 'core/chat/widgets/chat_request_overlay.dart';
|
|
||||||
import 'core/notifications/notification_service.dart';
|
|
||||||
import 'core/theme/halo_theme.dart';
|
|
||||||
import 'firebase_options.dart';
|
|
||||||
import 'router.dart';
|
|
||||||
|
|
||||||
void main() async {
|
/// Default entrypoint — delegates to the DEV flavor so a bare `flutter run`
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
/// (without -t) still works during local development. The `App` widget and the
|
||||||
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
|
/// shared startup logic now live in [bootstrap].
|
||||||
|
///
|
||||||
final messaging = FirebaseMessaging.instance;
|
/// For an explicit flavor, use the dedicated entrypoints instead:
|
||||||
await messaging.requestPermission();
|
/// - lib/main_dev.dart (flutter run --flavor dev -t lib/main_dev.dart --dart-define-from-file=env/dev.json)
|
||||||
|
/// - lib/main_staging.dart (flutter run --flavor staging -t lib/main_staging.dart --dart-define-from-file=env/staging.json)
|
||||||
runApp(const ProviderScope(child: App()));
|
/// - lib/main_prod.dart (flutter run --flavor prod -t lib/main_prod.dart --dart-define-from-file=env/prod.json)
|
||||||
}
|
Future<void> main() => bootstrap(
|
||||||
|
firebaseOptions: DefaultFirebaseOptions.currentPlatform,
|
||||||
class App extends ConsumerStatefulWidget {
|
flavor: 'dev',
|
||||||
const App({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
ConsumerState<App> createState() => _AppState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _AppState extends ConsumerState<App> with WidgetsBindingObserver {
|
|
||||||
bool _fcmRegistered = false;
|
|
||||||
// Session the chat WS was on at the moment we backgrounded. Restored on
|
|
||||||
// resume so a backgrounded mitra reconnects to the same chat once they
|
|
||||||
// foreground the app. Mirrors the customer-app fix (main.dart on the
|
|
||||||
// client side) — backend's sendMessage checks recipient WS readyState
|
|
||||||
// before falling back to FCM, so leaving the WS open while paused makes
|
|
||||||
// FCM never fire and the mitra misses customer messages in background.
|
|
||||||
String? _pausedChatSessionId;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
WidgetsBinding.instance.addObserver(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
|
||||||
if (state == AppLifecycleState.paused || state == AppLifecycleState.detached) {
|
|
||||||
ref.read(onlineStatusProvider.notifier).onAppPaused();
|
|
||||||
// Close the chat WS so backend `sendMessage` falls back to FCM when
|
|
||||||
// the customer sends a message. Stash the active session_id so we
|
|
||||||
// can rejoin it on resume.
|
|
||||||
final chatNotifier = ref.read(mitraChatProvider.notifier);
|
|
||||||
final sid = chatNotifier.connectedSessionId;
|
|
||||||
if (sid != null) {
|
|
||||||
_pausedChatSessionId = sid;
|
|
||||||
chatNotifier.disconnect();
|
|
||||||
}
|
|
||||||
} else if (state == AppLifecycleState.resumed) {
|
|
||||||
ref.read(onlineStatusProvider.notifier).onAppResumed();
|
|
||||||
// Reconnect to the chat we backgrounded out of, if any.
|
|
||||||
final saved = _pausedChatSessionId;
|
|
||||||
_pausedChatSessionId = null;
|
|
||||||
if (saved != null) {
|
|
||||||
// ignore: discarded_futures
|
|
||||||
ref.read(mitraChatProvider.notifier).connect(saved);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _registerFcmToken() {
|
|
||||||
if (_fcmRegistered) return;
|
|
||||||
_fcmRegistered = true;
|
|
||||||
Future(() async {
|
|
||||||
try {
|
|
||||||
final token = await FirebaseMessaging.instance.getToken();
|
|
||||||
if (token != null) {
|
|
||||||
await ref.read(apiClientProvider).post('/api/shared/device-token', data: {'token': token});
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
_fcmRegistered = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
// Listen for auth changes to load status and register FCM
|
|
||||||
ref.listen(mitraAuthProvider, (prev, next) {
|
|
||||||
final data = next.valueOrNull;
|
|
||||||
if (data is MitraAuthAuthenticatedData) {
|
|
||||||
ref.read(onlineStatusProvider.notifier).load();
|
|
||||||
_registerFcmToken();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
final router = ref.watch(routerProvider);
|
|
||||||
NotificationService.initialize(router);
|
|
||||||
NotificationService.onChatRequestTapped = (sessionId) {
|
|
||||||
ref.read(chatRequestProvider.notifier).setIncomingFromNotification(sessionId);
|
|
||||||
};
|
|
||||||
|
|
||||||
return ChatRequestOverlay(
|
|
||||||
child: MaterialApp.router(
|
|
||||||
title: 'Halo Bestie Mitra',
|
|
||||||
theme: haloThemeData(),
|
|
||||||
routerConfig: router,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
9
mitra_app/lib/main_dev.dart
Normal file
9
mitra_app/lib/main_dev.dart
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import 'bootstrap.dart';
|
||||||
|
import 'firebase/firebase_options_dev.dart';
|
||||||
|
|
||||||
|
/// DEV flavor entrypoint.
|
||||||
|
/// Run: flutter run --flavor dev -t lib/main_dev.dart --dart-define-from-file=env/dev.json
|
||||||
|
Future<void> main() => bootstrap(
|
||||||
|
firebaseOptions: DefaultFirebaseOptions.currentPlatform,
|
||||||
|
flavor: 'dev',
|
||||||
|
);
|
||||||
9
mitra_app/lib/main_prod.dart
Normal file
9
mitra_app/lib/main_prod.dart
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import 'bootstrap.dart';
|
||||||
|
import 'firebase/firebase_options_prod.dart';
|
||||||
|
|
||||||
|
/// PROD flavor entrypoint.
|
||||||
|
/// Run: flutter run --flavor prod -t lib/main_prod.dart --dart-define-from-file=env/prod.json
|
||||||
|
Future<void> main() => bootstrap(
|
||||||
|
firebaseOptions: DefaultFirebaseOptions.currentPlatform,
|
||||||
|
flavor: 'prod',
|
||||||
|
);
|
||||||
9
mitra_app/lib/main_staging.dart
Normal file
9
mitra_app/lib/main_staging.dart
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import 'bootstrap.dart';
|
||||||
|
import 'firebase/firebase_options_staging.dart';
|
||||||
|
|
||||||
|
/// STAGING flavor entrypoint.
|
||||||
|
/// Run: flutter run --flavor staging -t lib/main_staging.dart --dart-define-from-file=env/staging.json
|
||||||
|
Future<void> main() => bootstrap(
|
||||||
|
firebaseOptions: DefaultFirebaseOptions.currentPlatform,
|
||||||
|
flavor: 'staging',
|
||||||
|
);
|
||||||
191
requirement/analytics-events-reference.md
Normal file
191
requirement/analytics-events-reference.md
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
# Analytics Events Reference — client_app (GA4 / Firebase Analytics)
|
||||||
|
|
||||||
|
> Companion to `requirement/analytics-funnel-plan.md` (the design). This is the
|
||||||
|
> **single source of truth for what is actually instrumented** in client_app as
|
||||||
|
> of 2026-06-02. Any new event must be added to this table *before* it is coded
|
||||||
|
> (governance, plan §9).
|
||||||
|
>
|
||||||
|
> Scope: **client_app only** · `user_id` = customer UUID · **no PII** · client
|
||||||
|
> events live now; server (Measurement Protocol) events are deferred.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Identity & user properties
|
||||||
|
|
||||||
|
| Key | Set where | Values | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `user_id` | auth listener (main.dart) on resolve/upgrade | customer UUID | opaque; same row across anon→verified. Never phone/name. |
|
||||||
|
| `user_type` (user property) | same | `anonymous` \| `verified` | |
|
||||||
|
| `is_returning` (user property) | available via `setIsReturning` | `true` \| `false` | wire when "has ≥1 prior session" signal is read |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Event dictionary (custom events)
|
||||||
|
|
||||||
|
Type: **C** = fired client-side now · **S** = server-side (Measurement Protocol), **deferred** · **auto** = Firebase auto-collected.
|
||||||
|
|
||||||
|
| Event | Type | Params | Trigger / location |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `app_open` / `first_open` / `session_start` | auto | — | Firebase default |
|
||||||
|
| `screen_view` | auto (C) | `screen_name` | GoRouter observer — **page routes only** (see §4) |
|
||||||
|
| `curhat_start` | C | `funnel=activation`, `entry_point=home_primary` | Home "Aku Mau Curhat" CTA |
|
||||||
|
| `curhat_repeat_start` | C | `funnel=repeat` | Home "Aku Mau Curhat" (returning) / returning path |
|
||||||
|
| `bestie_choice_view` | C | — | `bestie_choice_sheet` shown (returning user with history) |
|
||||||
|
| `bestie_choice_select` | C | `choice=known_bestie\|new_bestie` | bestie-choice sheet card tap |
|
||||||
|
| `bestie_reselect` | C | `funnel=repeat`, `mitra_ref` (hashed) | `/bestie/history` row tap (targeted) |
|
||||||
|
| `verif_choice_view` | C | — | `verif_choice_sheet` shown (post anon-login) |
|
||||||
|
| `verif_choice_select` | C | `choice=verified\|anonymous` | verif-choice sheet decision (not on dismiss) |
|
||||||
|
| `auth_start` | C | `method=phone` | register screen "kirim kode" |
|
||||||
|
| `auth_otp_submit` | C | — | OTP screen submit |
|
||||||
|
| `auth_complete` | C | `user_type` | OTP verified resolve (verified) · display-name anon resolve (anonymous) |
|
||||||
|
| `onboarding_usp_view` | C | `verified` | USP screen initState |
|
||||||
|
| `payment_view` | C | `funnel`, `is_repeat` | `/payment/entry` initState |
|
||||||
|
| `payment_method_select` | C | `method` | payment-**channel** selection on `/payment/method` (once per change) — note: the chat/call mode picker is `curhat_mode_pick` on `/payment/method-pick` |
|
||||||
|
| `payment_started` ⭐ | C | `payment_request_id`, `amount`, `currency=IDR`, `method`, `funnel`, `is_repeat`, `product_type`, `duration_minutes` | `payment_method_screen._onPay`, **after** POST returns id |
|
||||||
|
| `pairing_matched` | C | `funnel` | `/chat/found` initState |
|
||||||
|
| `pairing_no_bestie` | C | `funnel` | `/chat/no-bestie` initState |
|
||||||
|
| `extension_offer_view` | C | `session_id` | `pricing_bottom_sheet` shown for extension (chat) |
|
||||||
|
| `chat_extension_requested` | C | `session_id` | user confirms extension (`PricingBottomSheet._onConfirm`) |
|
||||||
|
| `payment_confirmed` ⭐ | **S — deferred** | mirrors `payment_started` + `session_id`, `engagement_time_msec` | webhook → `payment_request.confirmed` |
|
||||||
|
| `payment_failed` | **S — deferred** | `payment_request_id`, `reason` | expiry/failure |
|
||||||
|
| `chat_session_start` ⭐ | **S — deferred** | `session_id`, `funnel`, `is_repeat`, `duration_minutes` | session.service start |
|
||||||
|
| `chat_session_end` | **S — deferred** | `session_id`, `end_reason`, `messages_count` | session end / timer |
|
||||||
|
|
||||||
|
`funnel`/`is_repeat` are derived from `paymentDraftNotifierProvider.targetedMitraId != null` (targeted mitra ⇒ repeat funnel).
|
||||||
|
|
||||||
|
### ⭐ Stitching keys carried on payment
|
||||||
|
Sent on `POST /api/client/payment-requests` body as `analytics:{ app_instance_id, ga_session_id }` (backend currently ignores). They let the deferred server `payment_confirmed` join the client funnel: `app_instance_id` (device/session), `user_id` (user), `payment_request_id` (exact attempt). Full rationale: plan §3.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Screen views tracked (page routes)
|
||||||
|
|
||||||
|
Auto `screen_view` fires for every GoRoute, mapped to a stable `screen_name` (path params stripped):
|
||||||
|
|
||||||
|
`splash · auth_display_name · auth_register · auth_otp · auth_set_name · auth_force_register · onboarding_usp_verified · onboarding_usp_anon · onboarding_notif_gate · home · profile · payment_entry · payment_discount_paywall · curhat_mode_pick · payment_duration_pick · payment_method · payment_waiting · payment_expired · chat_searching · chat_found · chat_no_bestie · chat_waiting_targeted · chat_session · chat_thank_you · chat_tab_aktif · chat_tab_pembayaran · chat_tab_selesai · chat_transcript · bestie_history`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Bottom sheets & modals
|
||||||
|
|
||||||
|
Sheets/dialogs (`showModalBottomSheet` / `showDialog`) push routes with a **null `RouteSettings.name`**, so the `FirebaseAnalyticsObserver` skips them — they get **no auto `screen_view`**. Funnel-relevant sheets are instead instrumented with explicit `*_view` / `*_select` events (logged from the sheet's show/onTap). Each tracked sheet fires a `view` when shown and a `select` when the user acts; the **gap between them = abandonment**.
|
||||||
|
|
||||||
|
| Sheet / dialog | Funnel relevance | Tracking |
|
||||||
|
|---|---|---|
|
||||||
|
| `verif_choice_sheet` (verify vs anonymous) | **high** | ✅ `verif_choice_view` + `verif_choice_select{choice}` |
|
||||||
|
| `bestie_choice_sheet` (new vs known bestie fork) | **high** | ✅ `bestie_choice_view` + `bestie_choice_select{choice}` |
|
||||||
|
| `pricing_bottom_sheet` (extension upsell in chat) | **medium** (monetization) | ✅ `extension_offer_view` + `chat_extension_requested` |
|
||||||
|
| `topic_selection_bottom_sheet` (pre-chat topic pick) | — | ⬜ **dead code** — `.show()` never called; track only once wired into a flow |
|
||||||
|
| `tanya_admin_sheet` (support) | low | ⬜ not tracked (negligible funnel value) |
|
||||||
|
| `bestie_unavailable_dialog` | low | ⬜ not tracked |
|
||||||
|
| `closing_message_sheet` (goodbye) | low | ⬜ not tracked |
|
||||||
|
|
||||||
|
**Why not the rest:** the `verif_choice` and `bestie_choice` *outcomes* are also inferable from downstream events (`auth_start` vs anon `payment_view`; `bestie_history` view vs direct `payment_view`) — the explicit events add the **abandonment** signal you can't otherwise see, plus one-step branch clarity. The extension pair is pure net-new (no other event covers extension take-rate). The low-tier sheets are support/edge surfaces and intentionally left untracked to avoid noise.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Visual flows
|
||||||
|
|
||||||
|
Two views of the same instrumentation:
|
||||||
|
- **5.1 Funnel event flow** — the abstract conversion funnel (what GA4 reports on).
|
||||||
|
- **5.2 Screen navigation map** — the real route/screen/sheet flow with each event pinned to where it fires (what you'll see live in DebugView).
|
||||||
|
|
||||||
|
### 5.1 Funnel event flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
classDef evt fill:#FFE3F0,stroke:#FF699F,color:#7A1F47;
|
||||||
|
classDef srv fill:#E3ECFF,stroke:#3B6FE0,color:#1B2E5A,stroke-dasharray:4 3;
|
||||||
|
classDef sheet fill:#FFF6D9,stroke:#D9A400,color:#5A4500;
|
||||||
|
|
||||||
|
AO([app_open / session_start]):::evt --> HOME[screen_view: home]
|
||||||
|
|
||||||
|
%% ---- Activation funnel ----
|
||||||
|
HOME --> CS{{bestie_choice_sheet — not viewed}}:::sheet
|
||||||
|
CS -->|new bestie| CSTART([curhat_start<br/>funnel=activation]):::evt
|
||||||
|
CSTART --> AUTH[screen_view: auth_register]
|
||||||
|
AUTH --> ASTART([auth_start method=phone]):::evt
|
||||||
|
ASTART --> OTP[screen_view: auth_otp]
|
||||||
|
OTP --> AOTP([auth_otp_submit]):::evt
|
||||||
|
AOTP --> ACOMP([auth_complete user_type]):::evt
|
||||||
|
ACOMP --> USP([onboarding_usp_view]):::evt
|
||||||
|
USP --> PV([payment_view funnel,is_repeat]):::evt
|
||||||
|
PV --> PMS([payment_method_select method]):::evt
|
||||||
|
PMS --> PSTART([payment_started ⭐<br/>+ app_instance_id, ga_session_id sent on POST]):::evt
|
||||||
|
PSTART --> PCONF([payment_confirmed ⭐<br/>SERVER — deferred]):::srv
|
||||||
|
PCONF --> PM{pairing}
|
||||||
|
PM -->|matched| PMATCH([pairing_matched]):::evt
|
||||||
|
PM -->|none| PNB([pairing_no_bestie]):::evt
|
||||||
|
PMATCH --> CSS([chat_session_start ⭐<br/>SERVER — deferred]):::srv
|
||||||
|
CSS --> CSE([chat_session_end<br/>SERVER — deferred]):::srv
|
||||||
|
|
||||||
|
%% ---- Repeat funnel ----
|
||||||
|
HOME --> RSTART([curhat_repeat_start<br/>funnel=repeat]):::evt
|
||||||
|
RSTART --> BHIST[screen_view: bestie_history]
|
||||||
|
BHIST --> BRESEL([bestie_reselect funnel=repeat]):::evt
|
||||||
|
BRESEL --> PV2([payment_view is_repeat=true]):::evt
|
||||||
|
PV2 --> PMS
|
||||||
|
```
|
||||||
|
|
||||||
|
Legend — pink = client event (live) · blue dashed = server event (deferred) · yellow = bottom sheet (not auto-tracked).
|
||||||
|
|
||||||
|
### 5.2 Screen navigation map (routes + sheets + events)
|
||||||
|
|
||||||
|
Real GoRouter routes (blue `screen_view` nodes), bottom sheets (yellow, **no** `screen_view`), and the exact event each transition fires (pink = live, dashed = deferred server). Both home CTAs read **"Aku Mau Curhat"**; the path taken depends on auth state, not the label.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
classDef screen fill:#E3ECFF,stroke:#3B6FE0,color:#1B2E5A;
|
||||||
|
classDef evt fill:#FFE3F0,stroke:#FF699F,color:#7A1F47;
|
||||||
|
classDef srv fill:#EFE3FF,stroke:#7B3BE0,color:#2E1B5A,stroke-dasharray:4 3;
|
||||||
|
classDef sheet fill:#FFF6D9,stroke:#D9A400,color:#5A4500;
|
||||||
|
|
||||||
|
SPL[/splash/]:::screen --> HOME[/home/]:::screen
|
||||||
|
|
||||||
|
%% ===== FRESH USER (activation) =====
|
||||||
|
HOME -->|tap CTA · curhat_start| DN[/auth/display-name/]:::screen
|
||||||
|
DN -.loginAnonymous.-> VCS{{verif_choice_sheet<br/>verif_choice_view}}:::sheet
|
||||||
|
VCS -->|verify · verif_choice_select| REG[/auth/register/]:::screen
|
||||||
|
VCS -->|lanjut tanpa verif · verif_choice_select| PE
|
||||||
|
REG -->|auth_start| OTP[/auth/otp/]:::screen
|
||||||
|
OTP -->|auth_otp_submit → auth_complete| UVU[/onboarding/verif/usp/]:::screen
|
||||||
|
UVU -->|onboarding_usp_view| PE
|
||||||
|
|
||||||
|
%% ===== RETURNING USER (repeat) =====
|
||||||
|
HOME -->|tap CTA · curhat_repeat_start| BCS{{bestie_choice_sheet<br/>bestie_choice_view}}:::sheet
|
||||||
|
BCS -->|new bestie · bestie_choice_select| PE
|
||||||
|
BCS -->|known bestie · bestie_choice_select| BHL[/bestie/history/]:::screen
|
||||||
|
BHL -->|tap row · bestie_reselect| PE
|
||||||
|
|
||||||
|
%% ===== SHARED PAYMENT SHELL =====
|
||||||
|
%% NOTE: /payment/method-pick is the chat-vs-call MODE picker (curhat_mode_pick),
|
||||||
|
%% NOT the channel picker. The channel picker is /payment/method (payment_method),
|
||||||
|
%% where payment_method_select fires.
|
||||||
|
PE[/payment/entry/<br/>payment_view/]:::screen --> MODE[/payment/method-pick/<br/>curhat_mode_pick/]:::screen
|
||||||
|
MODE -->|chat / call| DUR[/payment/duration-pick/<br/>payment_duration_pick/]:::screen
|
||||||
|
DUR --> PMETH[/payment/method/<br/>payment_method + payment_method_select/]:::screen
|
||||||
|
PMETH -->|tap bayar · payment_started ⭐<br/>+ app_instance_id & ga_session_id on POST| WP[/payment/waiting/:id/]:::screen
|
||||||
|
WP -.->|payment_confirmed ⭐ SERVER deferred| PCONF([backend webhook]):::srv
|
||||||
|
|
||||||
|
%% ===== PAIRING + CHAT =====
|
||||||
|
PCONF --> SRCH[/chat/searching/]:::screen
|
||||||
|
SRCH -->|matched · pairing_matched| FOUND[/chat/found/]:::screen
|
||||||
|
SRCH -->|none · pairing_no_bestie| NOB[/chat/no-bestie/]:::screen
|
||||||
|
FOUND --> SESS[/chat/session/:id/]:::screen
|
||||||
|
SESS -.->|chat_session_start / _end ⭐ SERVER deferred| SSRV([session.service]):::srv
|
||||||
|
SESS -->|tap perpanjang| EXT{{pricing_bottom_sheet<br/>extension_offer_view}}:::sheet
|
||||||
|
EXT -->|confirm · chat_extension_requested| SESS
|
||||||
|
```
|
||||||
|
|
||||||
|
Legend — blue = page route (auto `screen_view`) · pink label = client event fired on that transition · yellow = bottom sheet (no `screen_view`) · purple dashed = deferred server event.
|
||||||
|
|
||||||
|
> The three funnel-relevant sheets — **verify-vs-anonymous** (`verif_choice_sheet`), **new-vs-known-bestie** (`bestie_choice_sheet`), and the **extension upsell** (`pricing_bottom_sheet`) — each fire a `*_view` on show and a `*_select` / `chat_extension_requested` on action, so both the branch taken and sheet abandonment are measurable. See §4 for which sheets are intentionally left untracked.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. GA4 setup checklist (console)
|
||||||
|
|
||||||
|
- Register custom dimensions: `funnel`, `is_repeat`, `method`, `user_type`, `payment_request_id`, `product_type`, `end_reason`.
|
||||||
|
- Mark key events / conversions: `payment_confirmed`, `chat_session_start` (once server phase lands; until then `payment_started` is the furthest reliable client conversion).
|
||||||
|
- Build two Funnel Explorations (activation / repeat) filtered by `funnel` / `is_repeat`.
|
||||||
|
- Validate end-to-end in **DebugView** with a debug build before release.
|
||||||
235
requirement/analytics-funnel-plan.md
Normal file
235
requirement/analytics-funnel-plan.md
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
# Funnel Analytics Plan — Firebase Analytics (GA4)
|
||||||
|
|
||||||
|
> Status: PLAN / draft for review. Scope decisions (2026-06-02):
|
||||||
|
> **client_app only** · **hybrid client+server events** · **user_id = customer UUID, no PII** · **full-lifecycle taxonomy** (activation + repeat/retention).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Objectives
|
||||||
|
|
||||||
|
1. Measure the **activation funnel** (acquisition → first paid chat) and the **repeat/retention funnel** (returning user → curhat lagi → paid chat) in one consistent event taxonomy.
|
||||||
|
2. Attribute drop-off to specific screens/steps so product can act on it.
|
||||||
|
3. Keep authoritative money/session events **server-side** so they are never lost when the app is backgrounded or killed mid-payment.
|
||||||
|
4. **No PII** ever leaves the device into GA4 — no phone number, display name, or chat content. Identity is an opaque customer UUID only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. The two funnels (full lifecycle map)
|
||||||
|
|
||||||
|
Screen/route references are from `client_app/lib/router.dart`.
|
||||||
|
|
||||||
|
### Funnel A — Activation (first paid chat)
|
||||||
|
| # | Step (GA4 funnel step) | Event | Where |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | App open | `app_open` (auto) | Firebase auto |
|
||||||
|
| 2 | Home viewed | `screen_view{home}` | `/home` |
|
||||||
|
| 3 | Start curhat tapped | `curhat_start` | Home CTA "Aku Mau Curhat" |
|
||||||
|
| 4 | Auth started | `auth_start` | `/auth/display-name` / register |
|
||||||
|
| 5 | OTP submitted | `auth_otp_submit` | `/auth/otp` |
|
||||||
|
| 6 | Identified (verified or anon) | `auth_complete` | post-OTP / loginAnonymous |
|
||||||
|
| 7 | USP/onboarding seen | `onboarding_usp_view` | `/onboarding/*/usp` |
|
||||||
|
| 8 | Payment entry | `payment_view` | `/payment/entry` |
|
||||||
|
| 9 | Payment channel chosen | `payment_method_select` | `/payment/method` (channel picker; the chat/call mode picker is `curhat_mode_pick` on `/payment/method-pick`) |
|
||||||
|
| 10 | Payment created (invoice) | `payment_started` ⭐ | POST `/api/client/payment-requests` |
|
||||||
|
| 11 | **Payment confirmed** | `payment_confirmed` ⭐ **SERVER** | webhook → `payment_request.confirmed` |
|
||||||
|
| 12 | Paired with mitra | `pairing_matched` | `/chat/found` |
|
||||||
|
| 13 | Chat started | `chat_session_start` ⭐ **SERVER** | session.service start |
|
||||||
|
| 14 | Chat ended | `chat_session_end` **SERVER** | session end / timer |
|
||||||
|
|
||||||
|
### Funnel B — Repeat / retention
|
||||||
|
| # | Step | Event | Where |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | Returning home | `screen_view{home}` | `/home` (has history) |
|
||||||
|
| 2 | "Curhat lagi" | `curhat_repeat_start` | BestieChoiceSheet |
|
||||||
|
| 3 | Picked known bestie | `bestie_reselect` | `/bestie/history` |
|
||||||
|
| 4 | Payment created | `payment_started` ⭐ (`is_repeat=true`) | targeted payment |
|
||||||
|
| 5 | Payment confirmed | `payment_confirmed` ⭐ **SERVER** | webhook |
|
||||||
|
| 6 | Targeted pairing | `pairing_targeted_*` | `/chat/waiting-targeted` |
|
||||||
|
| 7 | Chat started | `chat_session_start` ⭐ **SERVER** | session start |
|
||||||
|
|
||||||
|
⭐ = the events that must join cleanly across client→server (see §3).
|
||||||
|
Funnel A and B share the same `payment_started` / `payment_confirmed` / `chat_session_start` events — they are distinguished by the **`funnel` / `is_repeat` event params**, not by separate event names. This keeps GA4 reports simple and lets one funnel exploration filter by param.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. ⭐ The hybrid join problem — "which payment event relates to which funnel?"
|
||||||
|
|
||||||
|
This is the central design question. Answer: **three identifiers travel with every payment, so a server-fired `payment_confirmed` lands on the exact same user, session, and attempt as the client-fired `payment_started`.**
|
||||||
|
|
||||||
|
### 3.1 The three join keys
|
||||||
|
|
||||||
|
| Key | Joins at level | Who sets it | How it flows |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `app_instance_id` | **device/app-instance** (required by GA4 MP for app streams) | Firebase SDK on device | client reads `FirebaseAnalytics.instance.appInstanceId`, sends it on payment-create, backend stores in `product_metadata`, replays it in the MP call |
|
||||||
|
| `user_id` | **user** (cross-device, cross-session) | our app | customer UUID set on both client `setUserId()` and server MP payload |
|
||||||
|
| `payment_request_id` | **attempt** (this specific purchase) | backend | returned by POST `/payment-requests`; client puts it on `payment_started`, backend puts the same value on `payment_confirmed` |
|
||||||
|
|
||||||
|
> **Why all three:** GA4's Measurement Protocol for app streams *requires* `app_instance_id` to attribute a server event to a user's stream — `user_id` alone will record the event but standard funnel/realtime reports won't stitch it to the device's session. `user_id` gives cross-device continuity (anon→verified). `payment_request_id` is the precise attempt-level join used in Explorations/BigQuery to tie one `payment_started` to its `payment_confirmed` (compute exact payment success rate & latency).
|
||||||
|
|
||||||
|
### 3.2 The flow, concretely
|
||||||
|
|
||||||
|
```
|
||||||
|
CLIENT payment_method_screen.dart
|
||||||
|
appInstanceId = await FirebaseAnalytics.instance.appInstanceId
|
||||||
|
POST /api/client/payment-requests
|
||||||
|
body: { ...draft, analytics: { app_instance_id, ga_session_id } }
|
||||||
|
← { id: <payment_request_id>, invoice_url }
|
||||||
|
analytics.log('payment_started', {
|
||||||
|
payment_request_id, amount, currency:'IDR', method,
|
||||||
|
funnel:'activation'|'repeat', is_repeat, product_type })
|
||||||
|
|
||||||
|
BACKEND payment.service.createPaymentRequest()
|
||||||
|
store analytics.app_instance_id + ga_session_id into product_metadata
|
||||||
|
|
||||||
|
BACKEND payment.service.confirmPayment() (fired from Xendit webhook)
|
||||||
|
emits 'payment_request.confirmed'
|
||||||
|
→ analytics subscriber → GA4 Measurement Protocol POST:
|
||||||
|
app_instance_id = product_metadata.app_instance_id ← stitches device/session
|
||||||
|
user_id = customer_id ← stitches user
|
||||||
|
events: [{ name:'payment_confirmed', params:{
|
||||||
|
payment_request_id, amount, currency, method,
|
||||||
|
funnel, is_repeat, session_id: ga_session_id,
|
||||||
|
engagement_time_msec: 1 }}]
|
||||||
|
```
|
||||||
|
|
||||||
|
`session_id` + `engagement_time_msec` in the MP params are what make GA4 attribute the server event to the **same session** as the client funnel (needed only for *session-scoped* funnel explorations; user-scoped funnels already work via `app_instance_id`+`user_id`). We capture `ga_session_id` client-side (`getSessionId()`) at payment-create and replay it.
|
||||||
|
|
||||||
|
### 3.3 Net result
|
||||||
|
- **User-scoped funnel** (default): works via `app_instance_id` + `user_id`.
|
||||||
|
- **Session-scoped funnel**: works because we replay `ga_session_id`.
|
||||||
|
- **Exact attempt analysis** (success rate, time-to-pay): join `payment_started`↔`payment_confirmed` on `payment_request_id` in BigQuery/Explore.
|
||||||
|
|
||||||
|
The same pattern covers `chat_session_start` / `chat_session_end` (server-authoritative) — keyed by `session_id` + `app_instance_id` + `user_id`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Identity & user properties
|
||||||
|
|
||||||
|
- `setUserId(customerId)` — the customer UUID (same row across anon→verified via `anonymous_customer_id`). Set on app start once auth resolves, and re-set after identity upgrade so the verified session continues the same `user_id`.
|
||||||
|
- **User properties** (low-cardinality, no PII):
|
||||||
|
- `user_type` = `anonymous` | `verified`
|
||||||
|
- `is_returning` = whether the customer has ≥1 prior confirmed session
|
||||||
|
- `acquisition_channel` (if/when known)
|
||||||
|
- **Never** set: phone, display name, email, chat text, mitra identity.
|
||||||
|
- Set GA4 data retention + IP anonymization per the mental-health sensitivity; document in privacy section §9.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Master event taxonomy
|
||||||
|
|
||||||
|
Naming: `snake_case`, object_action where natural, ≤40 chars. Reserved Firebase events (`app_open`, `screen_view`, `first_open`, `session_start`) are auto-collected — do not redefine.
|
||||||
|
|
||||||
|
| Event | Client/Server | Key params | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `screen_view` | client (auto via observer) | `screen_name` | go_router observer, §6 |
|
||||||
|
| `curhat_start` | client | `funnel='activation'`, `entry_point` | Home primary CTA |
|
||||||
|
| `curhat_repeat_start` | client | `funnel='repeat'` | returning CTA |
|
||||||
|
| `bestie_reselect` | client | `mitra_ref` (opaque) | `/bestie/history` |
|
||||||
|
| `auth_start` | client | `method` (phone/google/apple) | |
|
||||||
|
| `auth_otp_submit` | client | — | |
|
||||||
|
| `auth_complete` | client | `user_type` | |
|
||||||
|
| `onboarding_usp_view` | client | `verified` | |
|
||||||
|
| `payment_view` | client | `funnel`, `is_repeat` | `/payment/entry` |
|
||||||
|
| `payment_method_select` | client | `method` | |
|
||||||
|
| `payment_started` ⭐ | client | `payment_request_id`, `amount`, `currency`, `method`, `funnel`, `is_repeat`, `product_type`, `duration_minutes` | fired right after POST returns id |
|
||||||
|
| `payment_confirmed` ⭐ | **server (MP)** | same as above + `session_id`, `engagement_time_msec` | from webhook |
|
||||||
|
| `payment_failed` | **server (MP)** | `payment_request_id`, `reason` | expiry/failure |
|
||||||
|
| `pairing_matched` | client | `funnel` | `/chat/found` |
|
||||||
|
| `pairing_no_bestie` | client | `funnel` | `/chat/no-bestie` |
|
||||||
|
| `chat_session_start` ⭐ | **server (MP)** | `session_id`, `funnel`, `is_repeat`, `duration_minutes` | authoritative |
|
||||||
|
| `chat_session_end` | **server (MP)** | `session_id`, `end_reason`, `messages_count` | authoritative |
|
||||||
|
| `chat_extension_requested` | client | `session_id` | optional |
|
||||||
|
| `app_open` / `session_start` / `first_open` | auto | — | Firebase default |
|
||||||
|
|
||||||
|
> Keep custom params to those used in funnels/segments. Register the high-value ones as **custom dimensions** in GA4 (`funnel`, `is_repeat`, `method`, `user_type`, `payment_request_id`) so they're queryable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Client implementation (Flutter)
|
||||||
|
|
||||||
|
1. **Add deps** to `client_app/pubspec.yaml`: `firebase_analytics`. Re-run `flutterfire configure` if needed (firebase_core already present; messaging configured).
|
||||||
|
2. **Init** in bootstrap after `Firebase.initializeApp()`: enable collection, set default user properties.
|
||||||
|
3. **Analytics service wrapper** — `core/analytics/analytics.dart`:
|
||||||
|
- Thin façade over `FirebaseAnalytics.instance` with typed methods (`logCurhatStart`, `logPaymentStarted`, …) so event names/params are centralized and not stringly-typed at call sites.
|
||||||
|
- Exposes `appInstanceId()` and `sessionId()` helpers for the payment-create call.
|
||||||
|
- Riverpod provider `analyticsProvider` for injection.
|
||||||
|
4. **Auto screen_view** — add `FirebaseAnalyticsObserver` to `GoRouter(observers: [...])`. Map routes → clean `screen_name`s (avoid leaking path params like `:sessionId`).
|
||||||
|
5. **user_id wiring** — in the auth notifier listener, call `setUserId` + update `user_type`/`is_returning` user properties whenever auth state resolves/upgrades.
|
||||||
|
6. **Instrument the funnel call sites** per §5 (CTAs, OTP submit, payment screens, pairing screens). Fire `payment_started` only after the POST returns a `payment_request_id`.
|
||||||
|
|
||||||
|
> Pitfall guard (per client_app/CLAUDE.md): analytics calls inside widget teardown go in `deactivate()`, not `dispose()` — but prefer firing on the user action, not on screen disposal.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Backend implementation (Measurement Protocol)
|
||||||
|
|
||||||
|
1. **Config/env**: `GA4_API_SECRET`, `GA4_FIREBASE_APP_ID`, `GA4_MP_ENABLED` (default off, opt-in like Xendit/Fazpass flags). Endpoint: `https://www.google-analytics.com/mp/collect`.
|
||||||
|
2. **Capture identifiers**: extend POST `/api/client/payment-requests` to accept `analytics:{app_instance_id, ga_session_id}` and persist into `product_metadata` (already JSONB, already "for analytics").
|
||||||
|
3. **Analytics emitter service** — `services/analytics-mp.service.js`:
|
||||||
|
- `sendEvent({ appInstanceId, userId, name, params })` → builds MP payload, POSTs, logs failures non-fatally (analytics must never break payment).
|
||||||
|
- Always include `engagement_time_msec` and `session_id` for app-stream session attribution.
|
||||||
|
4. **Subscribe to existing internal events** (no new webhook plumbing needed):
|
||||||
|
- `payment_request.confirmed` → `payment_confirmed`
|
||||||
|
- payment expiry/failure → `payment_failed`
|
||||||
|
- session start/end (session.service) → `chat_session_start` / `chat_session_end`
|
||||||
|
5. **Validation**: use GA4 MP **debug endpoint** (`/debug/mp/collect`) in dev to assert payloads before enabling.
|
||||||
|
|
||||||
|
> Reliability: MP sends are fire-and-forget with a short timeout + retry-once; wrap in try/catch so a GA outage never affects the money path. Consider a lightweight outbox if we later need delivery guarantees.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. GA4 configuration (console)
|
||||||
|
|
||||||
|
1. Create/confirm the **Firebase Analytics → GA4 property** for client_app (Android + iOS app streams).
|
||||||
|
2. Register **custom dimensions**: `funnel`, `is_repeat`, `method`, `user_type`, `payment_request_id`, `product_type`, `end_reason`.
|
||||||
|
3. Build **two Funnel Explorations**:
|
||||||
|
- *Activation*: steps = `curhat_start → auth_complete → payment_started → payment_confirmed → pairing_matched → chat_session_start`, filter `funnel=activation`.
|
||||||
|
- *Repeat*: steps = `curhat_repeat_start → payment_started → payment_confirmed → chat_session_start`, filter `is_repeat=true`.
|
||||||
|
4. Mark **conversions/key events**: `payment_confirmed`, `chat_session_start`.
|
||||||
|
5. (Optional, recommended) Enable **BigQuery export** for attempt-level joins (`payment_started`↔`payment_confirmed` on `payment_request_id`) that GA's UI can't express precisely.
|
||||||
|
6. Use **DebugView** with a debug build to validate the full funnel end-to-end before release.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Privacy & governance
|
||||||
|
|
||||||
|
- **No PII** in events or user properties — enforce via the typed wrapper (no free-form string params at call sites).
|
||||||
|
- `user_id` is an opaque UUID; document the mapping policy and retention.
|
||||||
|
- Respect OS-level analytics/ads consent; gate collection behind app config so it can be disabled.
|
||||||
|
- Add a one-page **event dictionary** (this §5 table) to `requirement/` and keep it the single source of truth; any new event gets added here first (governance).
|
||||||
|
- Set GA4 data retention to the minimum that supports the funnels; enable IP anonymization.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Implementation phases / checklist
|
||||||
|
|
||||||
|
**Phase 1 — Client foundation**
|
||||||
|
- [ ] Add `firebase_analytics`; init + collection toggle
|
||||||
|
- [ ] `AnalyticsService` typed wrapper + Riverpod provider
|
||||||
|
- [ ] `FirebaseAnalyticsObserver` on GoRouter + screen_name map
|
||||||
|
- [ ] `setUserId` + user properties in auth listener
|
||||||
|
|
||||||
|
**Phase 2 — Client funnel events**
|
||||||
|
- [ ] Activation events (curhat_start … pairing_matched)
|
||||||
|
- [ ] Repeat events (curhat_repeat_start, bestie_reselect)
|
||||||
|
- [ ] `payment_started` after POST returns id; capture `app_instance_id` + `ga_session_id`, send on payment-create
|
||||||
|
|
||||||
|
**Phase 3 — Backend server events (hybrid)**
|
||||||
|
- [ ] Persist `analytics` identifiers into `product_metadata`
|
||||||
|
- [ ] `analytics-mp.service.js` + env flags
|
||||||
|
- [ ] Subscribe to `payment_request.confirmed` → `payment_confirmed`
|
||||||
|
- [ ] Subscribe to session start/end → `chat_session_*`
|
||||||
|
- [ ] Validate via MP debug endpoint
|
||||||
|
|
||||||
|
**Phase 4 — GA4 config + validation**
|
||||||
|
- [ ] Custom dimensions, conversions
|
||||||
|
- [ ] Two funnel explorations
|
||||||
|
- [ ] DebugView end-to-end pass on a real device
|
||||||
|
- [ ] (Optional) BigQuery export
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Open questions for product
|
||||||
|
1. Confirm GA4 property already exists for client_app (or do we create fresh)? Re separate prod/dev Firebase projects — see existing `firebase_env_strategy` note.
|
||||||
|
2. Do we want BigQuery export from day one (enables exact attempt-level payment analytics)?
|
||||||
|
3. Retention window + any consent-banner requirement for the mental-health context?
|
||||||
@@ -6,7 +6,7 @@ Operational decisions and dependency configuration for staging/production. Keep
|
|||||||
|
|
||||||
| Component | Service | Tier / Notes |
|
| Component | Service | Tier / Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Backend (public + internal) | GCP Cloud Run | Horizontal scaling; SIGTERM trapped for graceful drain ([server.js](../backend/src/server.js)) |
|
| Backend (public + internal) | Self-hosted Docker (VPS / Kubernetes / Docker Engine) | NOT Cloud Run. Container from [backend/Dockerfile](../backend/Dockerfile); horizontal scaling via replicas; SIGTERM trapped for graceful drain ([server.js](../backend/src/server.js)) |
|
||||||
| Database | GCP Cloud SQL (PostgreSQL) | Source of truth for all durable state |
|
| Database | GCP Cloud SQL (PostgreSQL) | Source of truth for all durable state |
|
||||||
| Pub/sub + cache | Valkey | Self-hosted on VM today; Memorystore Standard (HA) recommended for prod (see [§ Valkey](#valkey)) |
|
| Pub/sub + cache | Valkey | Self-hosted on VM today; Memorystore Standard (HA) recommended for prod (see [§ Valkey](#valkey)) |
|
||||||
| Networking | GCP VPC | Internal listener (port 3001) never exposed; CC reaches it via VPN |
|
| Networking | GCP VPC | Internal listener (port 3001) never exposed; CC reaches it via VPN |
|
||||||
@@ -88,7 +88,86 @@ The system is correct on any tier — HA reduces customer-visible latency spikes
|
|||||||
|
|
||||||
## Cloud Run
|
## Cloud Run
|
||||||
|
|
||||||
(Placeholder — fill in as we make decisions about region, min/max instances, concurrency, secrets manager wiring.)
|
(Placeholder for prod tuning — fill in as we make decisions about region, min/max instances, concurrency, secrets manager wiring.)
|
||||||
|
|
||||||
|
### Manual staging deploy runbook
|
||||||
|
|
||||||
|
Goal: stand up a staging backend so the Android **staging** flavor (`com.mybestie.staging`) has a real `API_BASE_URL` to talk to. Done manually for now (no CI/CD yet — see open ops).
|
||||||
|
|
||||||
|
> **Deploy target: self-hosted Docker** (VPS / Kubernetes / Docker Engine) — not Cloud Run. The backend ships a multi-stage [backend/Dockerfile](../backend/Dockerfile) (Node 20, non-root runtime, native `bcrypt` compiled in the build stage). Build with `docker build -t halobestie-backend ./backend`.
|
||||||
|
>
|
||||||
|
> **Full operational runbook — install Docker, build/push, migrate, run (Docker + Compose + k8s), and log mapping/rotation — lives in [backend/DEPLOY.md](../backend/DEPLOY.md).** The steps below are the staging-bring-up summary.
|
||||||
|
|
||||||
|
**A1 — Provision the staging database (Cloud SQL Postgres)**
|
||||||
|
1. Create a Cloud SQL Postgres instance (or a separate `halobestie_staging` DB on a shared instance). Pin the **same region** as the Cloud Run service.
|
||||||
|
2. Capture its connection string for `DATABASE_URL` (use the Cloud SQL connector / Unix socket form for Cloud Run, or private IP over the VPC connector).
|
||||||
|
3. Run migrations + seed against it:
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
DATABASE_URL=postgresql://... npm run db:migrate
|
||||||
|
DATABASE_URL=postgresql://... npm run db:seed
|
||||||
|
```
|
||||||
|
|
||||||
|
**A2 — Provision staging Valkey** — self-hosted Docker on the VM is fine for staging (`--appendonly yes`, see [§ Valkey](#valkey)). Note the `VALKEY_URL`.
|
||||||
|
|
||||||
|
**A3 — Staging Firebase Admin creds** — the app's staging `google-services.json` / `GoogleService-Info.plist` point at Firebase project **`my-bestie-876ec`**. The backend's `FIREBASE_SERVICE_ACCOUNT` **must be a service-account key from that same project**, or FCM push + token verification will silently target the wrong project. Mount it as a secret and set `FIREBASE_SERVICE_ACCOUNT_PATH` (or switch to a Secret Manager mount).
|
||||||
|
|
||||||
|
**A4 — Build the image + run migrations, then start the container.**
|
||||||
|
|
||||||
|
Build (on a build host or in CI), then push to your registry:
|
||||||
|
```bash
|
||||||
|
docker build -t <registry>/halobestie-backend:staging ./backend
|
||||||
|
docker push <registry>/halobestie-backend:staging
|
||||||
|
```
|
||||||
|
|
||||||
|
Run migrations as a **one-off** before (re)starting the service — never auto-migrate on boot (replica race):
|
||||||
|
```bash
|
||||||
|
docker run --rm --env-file backend/.env.staging \
|
||||||
|
<registry>/halobestie-backend:staging node src/db/migrate.js
|
||||||
|
# first deploy only:
|
||||||
|
docker run --rm --env-file backend/.env.staging \
|
||||||
|
<registry>/halobestie-backend:staging node src/db/seed.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the service (plain Docker Engine example; k8s = Deployment + Service with the same env/secrets and liveness/readiness probes on `:3000`):
|
||||||
|
```bash
|
||||||
|
docker run -d --name halobestie-staging \
|
||||||
|
--env-file backend/.env.staging \
|
||||||
|
-p 3000:3000 \
|
||||||
|
-v /path/to/firebase-sa.json:/secrets/firebase-sa.json:ro \
|
||||||
|
--restart unless-stopped \
|
||||||
|
<registry>/halobestie-backend:staging
|
||||||
|
```
|
||||||
|
- Publish **only** port 3000. The internal listener (3001) stays bound to `127.0.0.1` inside the container — do not map it.
|
||||||
|
- `FIREBASE_SERVICE_ACCOUNT_PATH` must point at the mounted path (e.g. `/secrets/firebase-sa.json`), not a baked-in file.
|
||||||
|
- Put a TLS-terminating reverse proxy (Nginx / Traefik / Caddy) in front for `https://staging-api.halobestie.com`.
|
||||||
|
|
||||||
|
Staging-specific env values (`backend/.env.staging`; see [backend/.env.example](../backend/.env.example) for the full list):
|
||||||
|
| Var | Staging value |
|
||||||
|
|---|---|
|
||||||
|
| `AUTH_JWT_SECRET` | a fresh secret — **not** the prod one |
|
||||||
|
| `XENDIT_ENABLED` | `false` until you wire test-mode keys + webhook |
|
||||||
|
| `XENDIT_SECRET_KEY` / `XENDIT_WEBHOOK_TOKEN` | Xendit **test** credentials |
|
||||||
|
| `XENDIT_SUCCESS/FAILURE_REDIRECT_URL` | staging backend's `/payment/return/*` URLs |
|
||||||
|
| `FAZPASS_ENABLED` | `false` (test-user OTP bypass path) unless testing real OTP |
|
||||||
|
| `CC_ORIGIN` | staging control-center origin (if deployed) |
|
||||||
|
| `ADMIN_EMAIL` / `ADMIN_PASSWORD` | staging control-center login |
|
||||||
|
|
||||||
|
> **Public listener only.** The internal listener (port 3001, control center) must stay off the public internet — don't expose it from this Cloud Run service. CC for staging, if needed, goes behind the VPC/VPN per the root architecture rules.
|
||||||
|
|
||||||
|
**A5 — Capture the URL.** Point a DNS record (e.g. `staging-api.halobestie.com`) at the host/reverse proxy and terminate TLS there. **This HTTPS URL is the value the app needs** in Phase B.
|
||||||
|
|
||||||
|
### App handoff (Phase B) — once A5 gives a URL
|
||||||
|
1. Put the real URL in [`client_app/env/staging.json`](../client_app/env/staging.json) + [`mitra_app/env/staging.json`](../mitra_app/env/staging.json) (`API_BASE_URL`), and remove the `_TODO` key from the client file.
|
||||||
|
2. Build the staging APK:
|
||||||
|
```bash
|
||||||
|
cd client_app
|
||||||
|
flutter build apk --flavor staging -t lib/main_staging.dart --dart-define-from-file=env/staging.json
|
||||||
|
```
|
||||||
|
Output: `build/app/outputs/flutter-apk/app-staging-release.apk`.
|
||||||
|
3. Distribute via **Firebase App Distribution** (debug-signed APK is accepted — no upload keystore needed for staging) or share the APK directly. `com.mybestie.staging` installs side-by-side with prod.
|
||||||
|
|
||||||
|
> **Release signing is still debug keys** ([client_app/android/app/build.gradle.kts](../client_app/android/app/build.gradle.kts) `release { ... }`). Fine for Firebase App Distribution / direct APK. A real upload keystore is only required if you later publish staging to a Play Store internal-testing track. iOS staging is **not** wired yet (only one `Runner.xcscheme` — no per-flavor schemes/build-configs).
|
||||||
|
|
||||||
## Cloud SQL
|
## Cloud SQL
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user