Compare commits

...

15 Commits

Author SHA1 Message Date
816e037a9a ci: add parameterized build workflow (env x target x platform)
Manually-triggered, build-only GitHub Actions workflow on GitHub-hosted
runners. Inputs: environment (staging/prod), target (all/customer/mitra/
backend/control_center), platform (android/ios/both). Runner split: iOS app
jobs run on macos-latest, all else on ubuntu-latest. Apps build debug-signed
APKs; control_center bakes VITE_API_BASE_URL; backend exports a docker-save
tarball. iOS jobs await per-flavor Xcode schemes + signing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 15:10:59 +08:00
91bdbd5289 build(backend): Dockerize for self-hosted deploy + deploy/log docs
Backend deploy target is self-hosted Docker (VPS / Kubernetes / Docker
Engine), not Cloud Run. Add a multi-stage Dockerfile (Node 20, bcrypt
compiled in build stage, non-root runtime), .dockerignore, a staging
docker-compose, and DEPLOY.md covering install, build, migrate, run, and
log mapping/rotation. Pin engines.node>=20. Update deployment.md runbook
and backend/CLAUDE.md infra line off Cloud Run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 15:10:59 +08:00
be20eee16b feat(client_app): open privacy policy in in-app webview
Add a reusable WebPageScreen (webview_flutter host with close button +
progress bar, no nav interception) and wire the profile 'kebijakan privasi'
menu item to open https://mybestieindonesia.com/privacy in it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 15:10:47 +08:00
d04f6a8a69 Merge branch 'feat/build-flavors'
dev/staging/prod build flavors for client_app + mitra_app: Android product
flavors, per-flavor Dart entrypoints + env files, per-flavor Firebase config
(both platforms, 3 projects), customer iOS re-based to com.asc.hallobestie.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

# Conflicts:
#	mitra_app/lib/firebase/firebase_options_dev.dart
2026-06-05 08:02:08 +08:00
48a1f8eb65 Merge branch 'feat/client-analytics-funnel'
GA4 funnel instrumentation + unified home CTA, AGP-8 Firebase Analytics
upload fix, analytics funnel docs, and dev static-OTP mitra helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 08:01:09 +08:00
22743c81e1 feat(build): add dev/staging/prod flavors for client_app + mitra_app
Android product flavors (.dev/.staging suffixes, prod clean) + per-flavor
Dart entrypoints, dart-define env files, and per-flavor Firebase config for
both platforms across 3 projects (halobestie-clone-dev / my-bestie-876ec /
my-bestie-production).

- Android: flavorDimensions("env") + productFlavors; @string/app_name label;
  per-flavor src/<flavor>/google-services.json (clients verified to match each
  applicationId).
- iOS: customer app re-based to the EXISTING App Store identity
  com.asc.hallobestie (dev/staging suffix it; ships as an update to the live
  app). mitra is a new app (com.mybestie.mitra). Per-flavor plists staged in
  ios/config/<flavor>/; Xcode scheme wiring deferred (Mac follow-up).
- firebase_options_{dev,staging,prod}.dart filled with real android + iOS
  values (regenerated from the native config files).
- BUILD_FLAVORS.md per app documents flavor table, build commands, iOS
  identity decision, and the remaining iOS Xcode steps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 22:21:50 +08:00
12cf9f80e9 chore(backend): add dev helper to provision a static-OTP mitra login
setup-test-mitra-otp.mjs adds a phone+mitra-scoped entry to the
app_config.test_otp_bypass allowlist and ensures an ACTIVE mitra row
(createMitra defaults inactive -> 403). Dev/QA convenience; the bypass is
checked before Fazpass in requestOtp so it short-circuits even when
FAZPASS_ENABLED=true. Idempotent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 21:57:42 +08:00
7e218decae docs(analytics): add funnel plan + live events reference
- analytics-funnel-plan.md: design rationale, hybrid client/server stitching,
  identity model, GA4 setup
- analytics-events-reference.md: live event dictionary + two Mermaid flow
  diagrams (funnel event flow + route/sheet navigation map)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 21:57:42 +08:00
f59fa0e27f fix(client/android): enable Firebase Analytics upload on AGP 8
google-services plugin 4.3.10 is incompatible with AGP 8.x: it generated
google_app_id into a values.xml but never merged it into the APK, so native
Firebase reported "Missing google_app_id. Firebase Analytics disabled" and
uploaded nothing (FCM still worked via the Dart-side init, masking it).

- bump com.google.gms.google-services 4.3.10 -> 4.4.2
- correct firebase_options.dart android appId from the stale
  com.halobestie.client.client_app registration to the com.mybestie app id
  (1:1068156046511:android:4f8fe9a3c7c14c57b8185a) so the Dart [DEFAULT]
  app matches google-services.json

Verified: google_app_id now merges into R.txt and logcat FA shows
"App measurement enabled ... Uploading data".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 21:57:42 +08:00
eeb4ea38fc feat(client/analytics): GA4 funnel instrumentation + unified home CTA
Add Firebase Analytics (GA4) funnel tracking to client_app:
- AnalyticsService typed wrapper (enum-gated, no PII) + analyticsProvider
- FirebaseAnalyticsObserver on GoRouter (screen_name via nameExtractor)
- user_id = customer UUID, user_type property, set on auth resolve/upgrade
- funnel events: curhat_start, curhat_repeat_start, auth_*, onboarding_usp_view,
  payment_view, payment_method_select, payment_started, pairing_matched/no_bestie
- bottom-sheet events: verif_choice_view/select, bestie_choice_view/select,
  extension_offer_view, chat_extension_requested
- payment_started carries app_instance_id + ga_session_id in the
  /payment-requests body for future server-side stitching (backend ignores)
- curhat_mode_pick screen name disambiguates the chat/call mode picker
  (/payment/method-pick) from the payment-channel picker (/payment/method)
- unify both home CTAs to "Aku Mau Curhat"

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 21:57:26 +08:00
76d74aa7b5 chore(splash): use app logo on icon background for native + flutter splash
Replace splash_chat_hebat with assets/icons/logo.png on @color/ic_launcher_background (customer #FF699F pink, mitra #FFFFFF white) across launch_background.xml (x2) and values-v31/styles.xml in both apps; copy logo.png into res/drawable. The mitra Flutter /splash screen still showed the old image — repoint it to assets/icons/logo.png (add assets/icons/ to mitra pubspec), keeping the route (it is the auth-loading gate). Native + flutter splash now match the launcher icon. Old splash_chat_hebat.png left in place but unused.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:27:39 +08:00
22048c678f fix(payment): autoDispose payment catalog so CC edits reflect without app restart
paymentCatalogProvider was a plain FutureProvider, which Riverpod caches for the whole app session — so control-center enable/disable/create of payment methods only showed up after an app restart. Backend was already correct (every mutator calls invalidatePaymentCatalog). Switch to FutureProvider.autoDispose so the catalog is dropped when the payment page is popped and re-fetched on re-open. Only watched by the payment method screen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:27:26 +08:00
529a38ae3f feat(backend): pin server timezone to UTC with startup assertion
Belt-and-suspenders, not a bug fix: storage (timestamptz) and timer math are already tz-independent. Add SERVER_TZ env (default UTC) via getServerTimezone(); db/client.js pins the DB session timezone (reads env directly to avoid an import cycle); server.js pins process.env.TZ and asserts at boot that the DB session matches (logs [tz] or a loud warning). Keeps any future date_trunc/::date reporting deterministic and surfaces a misconfigured server early. Documented in backend/CLAUDE.md + .env.example.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:27:16 +08:00
495eb98787 fix(db): widen customer_transactions.type to VARCHAR(128)
TransactionType.FIRST_SESSION_DISCOUNT ('first_session_discount', 22 chars) overflowed the VARCHAR(20) column, throwing in acceptPairingRequest AFTER the session was flipped to ACTIVE but before startSessionTimer/startSessionListener/PAIRED-notify ran. Every first-session-discount pairing thus half-completed: lost transaction row, no server-side timer, and a 500 to the mitra so its app never opened the chat. Widen the column (CREATE TABLE + idempotent ALTER). Deferred hardening (bookkeeping INSERT in the critical path) logged in TECH_DEBT.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:27:07 +08:00
6e87e9b6da fix(chat): render message timestamps in device-local time
Live chat bubbles read createdAt.hour/.minute directly, but server created_at (UTC, ISO-Z) was parsed without .toLocal() while optimistic sends used DateTime.now() (local). On any non-UTC device, your own messages showed local time and received/history messages showed UTC within the same conversation. Add .toLocal() at the history-load + incoming-WS parse sites in both apps so bubbles match the optimistic path and the transcript view. Session timer math was already tz-safe (Dart .difference uses absolute instants).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:26:57 +08:00
111 changed files with 3967 additions and 231 deletions

65
.github/workflows/README.md vendored Normal file
View 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
View 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

View File

@@ -10,6 +10,30 @@ to act on it without re-deriving the discussion.
## Backend
### `[2026-06-01]` Bookkeeping INSERT sits in the pairing critical path
**File:** `backend/src/services/pairing.service.js` (`acceptPairingRequest`, ~line 506)
**What happened:** the `INSERT INTO customer_transactions` runs *after* the session
is flipped to `ACTIVE` but *before* `startSessionTimer`, `startSessionListener`,
the customer `PAIRED` WS notify, and the other-mitra dismiss fan-out. A
`varchar(20)` overflow on `type = 'first_session_discount'` (22 chars) threw
there, so every first-session-discount pairing half-completed: no transaction
row, no server-side timer, no PAIRED push (customer recovered via polling), and a
500 returned to the mitra so its app never opened the chat.
**Fixed now:** column widened to `VARCHAR(128)` (migrate.js), so the INSERT no
longer throws.
**Why it's still debt:** a *bookkeeping* write can still abort *critical* pairing
steps if it ever fails again (constraint change, DB hiccup, future longer enum).
Hardening: either move the `customer_transactions` INSERT to the end of
`acceptPairingRequest`, or wrap it in a `try/catch` that logs-but-doesn't-throw,
so transaction recording can never again half-complete a pairing. Same applies to
the equivalent INSERT in `extension.service.js`.
---
### `[2026-05-11]` Public `GET /api/public/bestie/available` needs rate limiting before prod
**File:** `backend/src/routes/public/public.bestie-availability.routes.js`

22
backend/.dockerignore Normal file
View 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

View File

@@ -6,6 +6,12 @@ INTERNAL_HOST=127.0.0.1
# Database
DATABASE_URL=postgresql://user:password@localhost:5432/halobestie
# Server timezone. Pins the DB session + Node process to one zone. Leave as UTC
# in all environments — storage (timestamptz) and timer math are tz-independent,
# this just keeps any future date_trunc/::date-style SQL deterministic. The
# backend asserts the DB session matches this at startup.
SERVER_TZ=UTC
# Valkey / Redis
VALKEY_URL=redis://localhost:6379

View File

@@ -10,7 +10,7 @@ Fastify.js REST API serving both mobile apps and the internal control center.
- **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.
- **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
@@ -58,6 +58,16 @@ Two distinct knob-types exist; do not conflate them:
When a new value needs to flow from CC → app, prefer DB. When it's a deploy-fixed contract (e.g. heartbeat cadence the apps must honor, Xendit credentials, callback tokens), prefer env. CC inputs that depend on env values (e.g. min/max validation) read the env-derived value via the same config endpoint that surfaces the DB value, and the PATCH route validates against it.
## Timezone
**The backend is UTC end-to-end, and that is independent of the server/OS timezone.**
- All timestamp columns are `TIMESTAMPTZ`, which stores an absolute UTC instant (no per-row zone). Storage does NOT depend on the session/server timezone.
- All timestamp writes use server-computed instants (`NOW()`, `NOW() + interval`), never app-supplied wall-clock. There is no session-tz-dependent SQL (`date_trunc` / `::date` / `CURRENT_DATE` / `AT TIME ZONE`) anywhere today, so correctness does not rely on the timezone setting.
- The `postgres` driver returns JS `Date` (an absolute instant); Fastify serializes it via `.toISOString()`, so the API always emits ISO-8601 with a `Z`. Flutter parses that to a UTC `DateTime` and `.toLocal()`s **only at display time**. Rule for the apps: store/transport UTC, convert to local only when rendering a wall-clock.
`SERVER_TZ` (env, default `UTC`) is **belt-and-suspenders**, not a fix for any live bug: `db/client.js` pins the DB session timezone and `server.js` pins `process.env.TZ` to it, then asserts at boot that the DB session matches (logs `[tz] …` / a loud warning otherwise). This keeps any *future* `date_trunc`/`::date`-style reporting deterministic and surfaces a misconfigured server early. Getter: `getServerTimezone()` in `config.service.js` (`db/client.js` reads the env directly to avoid an import cycle — keep the `UTC` default in sync). The thing that genuinely matters operationally is NTP clock sync, not the timezone — a wrong wall-clock breaks `NOW()` and timers; a wrong timezone does not.
## FCM Channel Convention
Single channel `halobestie_chat_v2` is shared by both apps and ships the branded `halobestie_notif.ogg` sound. Backend FCM payloads should always target this channel ID via `android.notification.channelId`:

209
backend/DEPLOY.md Normal file
View 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
View 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"]

View 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:

View File

@@ -4,6 +4,9 @@
"description": "Halo Bestie backend API",
"main": "src/server.js",
"type": "module",
"engines": {
"node": ">=20"
},
"scripts": {
"dev": "node --watch src/server.js",
"start": "node src/server.js",

View 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)
})

View File

@@ -4,7 +4,13 @@ let sql
export const getDb = () => {
if (!sql) {
sql = postgres(process.env.DATABASE_URL)
// Pin the session timezone so timestamptz I/O and any future
// session-tz-dependent SQL (date_trunc / ::date / CURRENT_DATE) are
// deterministic regardless of the DB server's default. Defaults to UTC.
// Mirrors getServerTimezone() in config.service.js — kept inline here to
// avoid a client.js <-> config.service.js import cycle.
const timezone = (process.env.SERVER_TZ || 'UTC').trim()
sql = postgres(process.env.DATABASE_URL, { connection: { timezone } })
}
return sql
}

View File

@@ -226,12 +226,19 @@ const migrate = async () => {
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
customer_id UUID NOT NULL REFERENCES customers(id),
session_id UUID NOT NULL REFERENCES chat_sessions(id),
type VARCHAR(20) NOT NULL,
type VARCHAR(128) NOT NULL,
amount INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`
// Idempotent widen for DBs created when this column was VARCHAR(20): the
// TransactionType.FIRST_SESSION_DISCOUNT value 'first_session_discount' is
// 22 chars and overflowed varchar(20), throwing in acceptPairingRequest()
// *after* the session was already marked ACTIVE — losing the transaction row,
// the server-side timer, the PAIRED WS notify, and returning 500 to the mitra.
await sql`ALTER TABLE customer_transactions ALTER COLUMN type TYPE VARCHAR(128)`
await sql`
CREATE INDEX IF NOT EXISTS idx_customer_transactions_customer_id
ON customer_transactions (customer_id)

View File

@@ -11,13 +11,33 @@ import {
import { initFirebase } from './plugins/firebase.js'
import { restoreActiveTimers } from './services/session-timer.service.js'
import { expireStalePaymentRequests, registerPairingSubscriber } from './services/payment.service.js'
import { getXenditConfig } from './services/config.service.js'
import { getXenditConfig, getServerTimezone } from './services/config.service.js'
import { getDb } from './db/client.js'
const PUBLIC_PORT = process.env.PUBLIC_PORT || 3000
const INTERNAL_PORT = process.env.INTERNAL_PORT || 3001
const INTERNAL_HOST = process.env.INTERNAL_HOST || '127.0.0.1'
const start = async () => {
// Timezone assurance. Storage (timestamptz) and our instant-based timer math
// are timezone-independent, so this is belt-and-suspenders, not a fix for a
// live bug: it pins the Node process timezone for any Date formatting and
// then asserts the DB session timezone matches, so a misconfigured server/DB
// surfaces loudly at boot instead of silently skewing future date_trunc /
// ::date style queries. Defaults to UTC; override via SERVER_TZ.
const serverTz = getServerTimezone()
process.env.TZ = serverTz
const [dbTz] = await getDb()`SHOW timezone`
if (dbTz?.TimeZone !== serverTz) {
console.warn(
`[tz] WARNING: DB session timezone is "${dbTz?.TimeZone}" but SERVER_TZ="${serverTz}". ` +
'timestamptz storage is unaffected, but session-tz-dependent SQL may skew. ' +
'Check the DB server default / connection options.'
)
} else {
console.log(`[tz] process + DB session pinned to ${serverTz}`)
}
// Phase 5: fail fast if XENDIT_ENABLED=true without the required credentials.
// Bad config explodes at startup rather than at the first /payment-requests POST.
const xc = getXenditConfig()

View File

@@ -114,6 +114,20 @@ export const getMitraHeartbeatCadenceSeconds = () => {
return Number.isFinite(parsed) && parsed >= 5 ? parsed : 30
}
// Server timezone. Defaults to UTC and should essentially never be changed —
// see backend/CLAUDE.md "Timezone" note. timestamptz storage and all of our
// instant-based timer math are timezone-INDEPENDENT, so this is belt-and-
// suspenders: it pins the DB session + Node process to one zone so that any
// FUTURE session-tz-dependent SQL (date_trunc / ::date / CURRENT_DATE) and any
// stray local-time Date formatting stay deterministic across deploys.
// NOTE: db/client.js reads `process.env.SERVER_TZ || 'UTC'` directly (it cannot
// import this module without a cycle); keep the default in sync.
export const getServerTimezone = () => {
const raw = process.env.SERVER_TZ
if (!raw || raw.trim() === '') return 'UTC'
return raw.trim()
}
// --- Valkey availability mirror — env-driven cadences ---
//
// Per requirement/valkey-online-mirror-plan.md. All three are operational

184
client_app/BUILD_FLAVORS.md Normal file
View 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

View File

@@ -24,7 +24,10 @@ android {
}
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"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
@@ -34,6 +37,34 @@ android {
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 {
release {
// TODO: Add your own signing config for the release build.

View File

@@ -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": {
"mobilesdk_app_id": "1:1068156046511:android:8a873c8b7e64410ab8185a",

View File

@@ -3,7 +3,7 @@
Phase 4 Stage 4 notif-gate via permission_handler. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<application
android:label="HaloBestie"
android:label="@string/app_name"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">

View File

@@ -1,12 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- Same pink as the launcher icon background (ic_launcher_background = #FF699F),
so the native launch splash matches the in-app logo tile. -->
<item android:drawable="@color/ic_launcher_background" />
<!-- Square box (logo.png is 4500x4500) so the glyph isn't distorted. -->
<item
android:width="200dp"
android:height="168dp"
android:height="200dp"
android:gravity="center">
<bitmap
android:gravity="fill"
android:src="@drawable/splash_chat_hebat" />
android:src="@drawable/logo" />
</item>
</layer-list>

View File

@@ -1,12 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- Same pink as the launcher icon background (ic_launcher_background = #FF699F),
so the native launch splash matches the in-app logo tile. -->
<item android:drawable="@color/ic_launcher_background" />
<!-- Square box (logo.png is 4500x4500) so the glyph isn't distorted. -->
<item
android:width="200dp"
android:height="168dp"
android:height="200dp"
android:gravity="center">
<bitmap
android:gravity="fill"
android:src="@drawable/splash_chat_hebat" />
android:src="@drawable/logo" />
</item>
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

View File

@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowSplashScreenBackground">@android:color/white</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_chat_hebat</item>
<item name="android:windowSplashScreenBackground">@color/ic_launcher_background</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/logo</item>
</style>
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>

View 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"
}

View 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.

View 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"
}

View File

@@ -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.

View File

@@ -21,7 +21,7 @@ plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false
// 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
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
}

4
client_app/env/dev.json vendored Normal file
View 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
View File

@@ -0,0 +1,4 @@
{
"API_BASE_URL": "https://api.halobestie.com",
"FLAVOR": "prod"
}

5
client_app/env/staging.json vendored Normal file
View 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)"
}

View File

@@ -496,7 +496,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.mybestie;
PRODUCT_BUNDLE_IDENTIFIER = com.asc.hallobestie;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
@@ -513,7 +513,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.mybestie.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.asc.hallobestie.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -531,7 +531,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.mybestie.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.asc.hallobestie.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -547,7 +547,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.mybestie.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.asc.hallobestie.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -679,7 +679,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.mybestie;
PRODUCT_BUNDLE_IDENTIFIER = com.asc.hallobestie;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -702,7 +702,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.mybestie;
PRODUCT_BUNDLE_IDENTIFIER = com.asc.hallobestie;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;

View File

@@ -87,7 +87,7 @@
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.mybestie</string>
<string>com.asc.hallobestie</string>
<key>CFBundleURLSchemes</key>
<array>
<string>halobestie</string>

View 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.

View 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>

View 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.

View 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>

View 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.

View 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>

View 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.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.

View 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);

View 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

View File

@@ -6,7 +6,7 @@ part of 'auth_notifier.dart';
// RiverpodGenerator
// **************************************************************************
String _$authHash() => r'f0382b1380749cebe8182ad221023926768ebb92';
String _$authHash() => r'76cd43babe5503d35a35c8fd23ba32afbc4c8c2d';
/// See also [Auth].
@ProviderFor(Auth)

View File

@@ -245,7 +245,9 @@ class Chat extends _$Chat {
content: m['content'] as String,
type: m['type'] as String? ?? MessageType.text,
status: m['status'] as String? ?? MessageStatus.sent,
createdAt: DateTime.parse(m['created_at'] as String),
// Server sends UTC (ISO-8601 with Z); render in device-local time so
// bubbles match optimistic sends (DateTime.now()) + the transcript view.
createdAt: DateTime.parse(m['created_at'] as String).toLocal(),
)).toList();
final token = ref.read(authBridgeProvider).accessToken;
@@ -351,7 +353,8 @@ class Chat extends _$Chat {
content: data['content'] as String,
type: data['message_type'] as String? ?? MessageType.text,
status: MessageStatus.sent,
createdAt: DateTime.parse(data['created_at'] as String),
// UTC from server → device-local for display (see history-load note).
createdAt: DateTime.parse(data['created_at'] as String).toLocal(),
);
state = current.copyWith(messages: [...current.messages, msg]);
markDelivered([msg.id]);

View File

@@ -30,7 +30,7 @@ final chatRemainingSecondsProvider = AutoDisposeStreamProvider<int>.internal(
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef ChatRemainingSecondsRef = AutoDisposeStreamProviderRef<int>;
String _$chatHash() => r'a42bf404f5b38034c6c9511259b64b1092af3caa';
String _$chatHash() => r'56f019ce6e527128ab42a71f56220c5412cfec0f';
/// See also [Chat].
@ProviderFor(Chat)

View File

@@ -6,7 +6,7 @@ part of 'session_closure_notifier.dart';
// RiverpodGenerator
// **************************************************************************
String _$sessionClosureHash() => r'a9aaf26da64d3489497d057e2b05741c08444e07';
String _$sessionClosureHash() => r'1ab9df044138115e232b3df494e2895177d9d66d';
/// See also [SessionClosure].
@ProviderFor(SessionClosure)

View File

@@ -6,7 +6,7 @@ part of 'pairing_notifier.dart';
// RiverpodGenerator
// **************************************************************************
String _$pairingHash() => r'd39980fe5b82348d03485006d0534ab597b93ceb';
String _$pairingHash() => r'e66bbf67e1013b3d25230ed01fe2595bff943b3a';
/// See also [Pairing].
@ProviderFor(Pairing)

View 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),
);
}
}

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/analytics/analytics_service.dart';
import '../../../core/api/api_client_provider.dart';
import '../../../core/auth/auth_notifier.dart';
import '../../../core/theme/halo_tokens.dart';
@@ -35,6 +36,9 @@ class _DisplayNameScreenState extends ConsumerState<DisplayNameScreen> {
final data = next.valueOrNull;
if (data is AuthAnonymousData && !_routedAfterLogin) {
_routedAfterLogin = true;
// Anonymous identity established — activation step 6.
// ignore: discarded_futures
ref.read(analyticsProvider).logAuthComplete(AnalyticsUserType.anonymous);
_proceedAfterLogin();
}
});
@@ -82,12 +86,17 @@ class _DisplayNameScreenState extends ConsumerState<DisplayNameScreen> {
return;
}
// ignore: discarded_futures
ref.read(analyticsProvider).logVerifChoiceView();
final choice = await VerifChoiceSheet.show(context);
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;
return;
}
// ignore: discarded_futures
ref.read(analyticsProvider).logVerifChoiceSelect(choice);
if (!mounted) return;
await routeForVerifChoice(context, ref, choice);
}

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/analytics/analytics_service.dart';
import '../../../core/api/api_client_provider.dart';
import '../../../core/auth/auth_notifier.dart';
import '../../../core/constants.dart';
@@ -75,6 +76,14 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
if (_errorMessage != null && mounted) {
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();
if (code.length == _kOtpLength && !_autoSubmitted && _otpRequestId != null) {
_autoSubmitted = true;
// ignore: discarded_futures
ref.read(analyticsProvider).logAuthOtpSubmit();
ref.read(authProvider.notifier).verifyOtp(_otpRequestId!, code);
}
}

View File

@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/analytics/analytics_service.dart';
import '../../../core/auth/auth_notifier.dart';
import '../../../core/constants.dart';
import '../../../core/theme/halo_tokens.dart';
@@ -222,8 +223,15 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
: 'kirim kode',
fullWidth: true,
onPressed: canSubmit
? () =>
ref.read(authProvider.notifier).requestOtp(_e164Phone())
? () {
// ignore: discarded_futures
ref
.read(analyticsProvider)
.logAuthStart(AnalyticsAuthMethod.phone);
ref
.read(authProvider.notifier)
.requestOtp(_e164Phone());
}
: null,
),
if (!fromProfile) ...[

View File

@@ -1,9 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/analytics/analytics_service.dart';
import '../../../core/chat/active_session_notifier.dart';
import '../../../core/pairing/pairing_notifier.dart';
import '../../../core/theme/halo_tokens.dart';
import '../../payment/state/payment_draft_provider.dart';
import '../../../core/theme/widgets/widgets.dart';
/// Phase 4 Stage 5 — S9 Match-found screen.
@@ -35,6 +37,15 @@ class _BestieFoundScreenState extends ConsumerState<BestieFoundScreen> {
@override
void 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) {
if (!mounted) return;
if (next is PairingActiveData) {

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/analytics/analytics_service.dart';
import '../../../core/chat/active_session_notifier.dart';
import '../../../core/chat/chat_notifier.dart';
import '../../../core/chat/session_closure_notifier.dart';
@@ -512,6 +513,8 @@ class _TimerBanner extends ConsumerWidget {
return _BannerKind.none;
}));
void onExtend() {
// ignore: discarded_futures
ref.read(analyticsProvider).logExtensionOfferView(sessionId: sessionId);
PricingBottomSheet.showForExtension(
context,
sessionId: sessionId,

View File

@@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/analytics/analytics_service.dart';
import '../../../core/pairing/pairing_notifier.dart';
import '../../payment/state/payment_draft_provider.dart';
/// 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
/// home. PopScope falls back to home for deep-link entry per project memory
/// rule "Deep-link pop fallback".
class NoBestieScreen extends ConsumerWidget {
class NoBestieScreen extends ConsumerStatefulWidget {
const NoBestieScreen({super.key});
@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(
canPop: true,
onPopInvokedWithResult: (didPop, _) {

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/analytics/analytics_service.dart';
import '../../../core/chat/chat_opening_provider.dart';
import '../../../core/chat/session_closure_notifier.dart';
import '../../../core/constants.dart';
@@ -85,6 +86,10 @@ class _PricingBottomSheetState extends ConsumerState<PricingBottomSheet> {
}
void _onConfirm(PriceTier tier) {
// ignore: discarded_futures
ref.read(analyticsProvider).logChatExtensionRequested(
sessionId: widget.extensionSessionId,
);
Navigator.of(context).pop();
ref.read(sessionClosureProvider.notifier).requestExtension(
widget.extensionSessionId,

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/analytics/analytics_service.dart';
import '../../core/auth/auth_notifier.dart';
import '../../core/auth/onboarding_intent_provider.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
/// when they have prior history, otherwise jump to the new-payment shell.
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;
try {
hasHistory = await ref.read(bestieHistoryHasItemsProvider.future);
@@ -89,6 +95,8 @@ class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObse
}
if (!context.mounted) return;
if (hasHistory) {
// ignore: discarded_futures
ref.read(analyticsProvider).logBestieChoiceView();
await BestieChoiceSheet.show(context);
return;
}
@@ -101,6 +109,9 @@ class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObse
/// (the call-sign check); display_name_screen kicks off `loginAnonymous`
/// and pushes into the verif-choice sheet.
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');
}
@@ -172,7 +183,7 @@ class _SHome1stView extends ConsumerWidget {
const _GreetingSubtitle(),
const SizedBox(height: 32),
_PrimaryCTA(
label: 'aku mau curhat',
label: 'Aku Mau Curhat',
enabled: mitraAvailable,
onPressed: onCTA,
),
@@ -395,7 +406,7 @@ class _SHomeReturningView extends ConsumerWidget {
activeSessionAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, __) => _PrimaryCTA(
label: 'curhat sama bestie baru',
label: 'Aku Mau Curhat',
enabled: mitraAvailable,
onPressed: onCTA,
),
@@ -415,7 +426,7 @@ class _SHomeReturningView extends ConsumerWidget {
);
}
return _PrimaryCTA(
label: 'curhat sama bestie baru',
label: 'Aku Mau Curhat',
enabled: mitraAvailable,
onPressed: onCTA,
);

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/analytics/analytics_service.dart';
import '../../../core/constants.dart';
import '../../../core/theme/halo_tokens.dart';
import '../../../core/theme/widgets/widgets.dart';
@@ -132,6 +133,13 @@ class BestieHistoryListScreen extends ConsumerWidget {
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
// multi-screen payment flow (entry → method → waiting →
// notif-gate → searching) reads it back to fire the

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/analytics/analytics_service.dart';
import '../../../core/theme/halo_tokens.dart';
import '../../../core/theme/widgets/widgets.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.',
icon: Icons.favorite_outline,
onTap: () {
// ignore: discarded_futures
ref.read(analyticsProvider).logBestieChoiceSelect(knownBestie: true);
Navigator.of(context).pop();
context.push('/bestie/history');
},
@@ -62,6 +65,8 @@ class BestieChoiceSheet extends ConsumerWidget {
subtitle: 'cari bestie baru yang siap dengerin sekarang.',
icon: Icons.auto_awesome_outlined,
onTap: () {
// ignore: discarded_futures
ref.read(analyticsProvider).logBestieChoiceSelect(knownBestie: false);
// explicit reset — this branch is blast-only, clear any stale targeted mitra
ref.read(paymentDraftNotifierProvider.notifier).reset();
Navigator.of(context).pop();

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/analytics/analytics_service.dart';
import '../../../core/theme/halo_tokens.dart';
import '../../../core/theme/widgets/widgets.dart';
import '../usp_seen_provider.dart';
@@ -11,11 +12,30 @@ import '../usp_seen_provider.dart';
///
/// `verified` ➞ USP → OTP (`/auth/register`).
/// `anonymous` ➞ USP → `/payment/method-pick`.
class UspScreen extends ConsumerWidget {
class UspScreen extends ConsumerStatefulWidget {
final bool 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 = [
_UspCard(
icon: Icons.bolt_outlined,
@@ -40,7 +60,7 @@ class UspScreen extends ConsumerWidget {
];
@override
Widget build(BuildContext context, WidgetRef ref) {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Padding(
@@ -94,7 +114,7 @@ class UspScreen extends ConsumerWidget {
HaloButton(
label: 'aku ngerti, lanjut',
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
// hits VerifChoice, this screen is skipped.
await ref.read(uspSeenProvider.notifier).markSeen();
if (!context.mounted) return;
if (verified) {
if (widget.verified) {
context.push('/auth/register');
} else {
context.push('/payment/method-pick');

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/analytics/analytics_service.dart';
import '../../../core/auth/onboarding_intent_provider.dart';
import '../../../core/chat/chat_opening_provider.dart';
import '../../../core/theme/halo_tokens.dart';
@@ -38,6 +39,18 @@ class _PaymentEntryScreenState extends ConsumerState<PaymentEntryScreen> {
// inherit a stale onboarding intent.
ref.read(onboardingIntentProvider.notifier).state =
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,
);
});
}

View File

@@ -2,6 +2,7 @@ import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/analytics/analytics_service.dart';
import '../../../core/api/api_client_provider.dart';
import '../../../core/constants.dart';
import '../../../core/theme/halo_tokens.dart';
@@ -53,7 +54,21 @@ class _PaymentMethodScreenState extends ConsumerState<PaymentMethodScreen> {
_error = null;
});
final api = ref.read(apiClientProvider);
final analytics = ref.read(analyticsProvider);
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>{
'mode': draft.mode.value,
'duration_minutes': draft.durationMinutes,
@@ -61,11 +76,27 @@ class _PaymentMethodScreenState extends ConsumerState<PaymentMethodScreen> {
'is_first_session_discount': draft.isFirstSessionDiscount,
'method': _selectedCode,
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 data = response['data'] as Map<String, dynamic>;
final paymentId = data['id'] as String;
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;
context.push('/payment/waiting/$paymentId');
} on DioException catch (e) {
@@ -216,10 +247,20 @@ class _PaymentMethodScreenState extends ConsumerState<PaymentMethodScreen> {
_expandedGroupIds.remove(g.id);
}
}),
onSelect: (code) => setState(() {
_selectedCode = code;
_error = null;
}),
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;
_error = null;
});
},
);
}).toList(),
);

View File

@@ -106,7 +106,15 @@ const PaymentCatalog kFallbackPaymentCatalog = _FallbackCatalog();
/// App-facing catalog. Calls `GET /api/client/payment-methods`; on 5xx or
/// network error returns [kFallbackPaymentCatalog] so checkout never
/// hard-fails. See `requirement/phase5-payment-catalog-plan.md` §5.
final paymentCatalogProvider = FutureProvider<PaymentCatalog>((ref) async {
///
/// `autoDispose`: a plain FutureProvider caches its result for the whole app
/// session, so control-center edits to payment methods (enable/disable/create)
/// only showed up after an app restart. autoDispose drops the cached catalog
/// once the payment screen is popped (no listeners), so re-opening the payment
/// page re-fetches the now-current catalog from the backend (whose own cache is
/// invalidated on every mutation).
final paymentCatalogProvider =
FutureProvider.autoDispose<PaymentCatalog>((ref) async {
final api = ref.read(apiClientProvider);
try {
final res = await api.get('/api/client/payment-methods');

View File

@@ -7,7 +7,7 @@ part of 'payment_draft_provider.dart';
// **************************************************************************
String _$paymentDraftNotifierHash() =>
r'1c81b22f25f525cd290f54618bee0b69de792998';
r'e489a593f5e1cc2794d13566a9cf960bb89e45c6';
/// See also [PaymentDraftNotifier].
@ProviderFor(PaymentDraftNotifier)

View File

@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/auth/auth_notifier.dart';
import '../../core/theme/halo_tokens.dart';
import '../../core/widgets/web_page_screen.dart';
import '../home/widgets/halo_tab_bar.dart';
/// "Kamu" tab — profile screen.
@@ -78,7 +79,14 @@ class ProfileScreen extends ConsumerWidget {
_MenuItemData(
icon: Icons.lock_outline,
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),

View File

@@ -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
import 'package:firebase_core/firebase_core.dart' show FirebaseOptions;
import 'package:flutter/foundation.dart'
show defaultTargetPlatform, kIsWeb, TargetPlatform;
/// Default [FirebaseOptions] for use with your Firebase apps.
///
/// Example:
/// ```dart
/// import 'firebase_options.dart';
/// // ...
/// await Firebase.initializeApp(
/// options: DefaultFirebaseOptions.currentPlatform,
/// );
/// ```
class DefaultFirebaseOptions {
/// [FirebaseOptions] for the DEV environment (project halobestie-clone-dev).
class DevFirebaseOptions {
static FirebaseOptions get currentPlatform {
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) {
case TargetPlatform.android:
@@ -48,7 +43,7 @@ class DefaultFirebaseOptions {
static const FirebaseOptions android = FirebaseOptions(
apiKey: 'AIzaSyDFWlLSWytqwI7LSdUbVrO7J5De9L2LV2U',
appId: '1:1068156046511:android:f30784f6b0423131b8185a',
appId: '1:1068156046511:android:1f589ed358ccdad0b8185a',
messagingSenderId: '1068156046511',
projectId: 'halobestie-clone-dev',
storageBucket: 'halobestie-clone-dev.firebasestorage.app',
@@ -56,21 +51,10 @@ class DefaultFirebaseOptions {
static const FirebaseOptions ios = FirebaseOptions(
apiKey: 'AIzaSyAQnB5hbj0T5tE4JQZQ9Tx6Whp_u15obMI',
appId: '1:1068156046511:ios:b781f67a57d6db7bb8185a',
appId: '1:1068156046511:ios:bc9098ffc2c2913ab8185a',
messagingSenderId: '1068156046511',
projectId: 'halobestie-clone-dev',
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',
);
}
}

View 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',
);
}

View 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',
);
}

View File

@@ -1,9 +1,11 @@
import 'dart:async';
import 'package:firebase_analytics/firebase_analytics.dart';
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/analytics/analytics_service.dart';
import 'core/api/api_client_provider.dart';
import 'core/auth/auth_notifier.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/pairing/pairing_notifier.dart';
import 'core/theme/halo_theme.dart';
import 'firebase_options.dart';
import 'firebase/firebase_options_dev.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();
// 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.
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;
await messaging.requestPermission();
@@ -34,6 +55,16 @@ void main() async {
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 {
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
Widget build(BuildContext context) {
// FCM registration on auth.
// FCM registration + analytics identity on auth.
ref.listen(authProvider, (prev, next) {
final data = next.valueOrNull;
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.
ref.read(chatProvider.notifier).disconnect();
}
_syncAnalyticsIdentity(data);
});
// Global chat WebSocket lifecycle: connect whenever the user has an

View 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',
);
}

View 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',
);
}

View 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',
);
}

View File

@@ -1,6 +1,8 @@
import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'core/analytics/analytics_service.dart';
import 'core/auth/auth_notifier.dart';
import 'core/auth/onboarding_intent_provider.dart';
import 'features/auth/screens/display_name_screen.dart';
@@ -54,12 +56,62 @@ class RouterNotifier extends ChangeNotifier {
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) {
final notifier = RouterNotifier(ref);
final analyticsObserver = FirebaseAnalyticsObserver(
analytics: FirebaseAnalytics.instance,
nameExtractor: _screenNameFor,
);
return GoRouter(
initialLocation: kThemePreviewEnabled ? '/_theme_preview' : '/splash',
refreshListenable: notifier,
observers: [analyticsObserver],
redirect: (context, state) {
// Theme preview is dev-only and intentionally bypasses auth + onboarding
// gates so it can be opened on any device build.

View File

@@ -5,6 +5,7 @@
import FlutterMacOS
import Foundation
import firebase_analytics
import firebase_core
import firebase_messaging
import flutter_local_notifications
@@ -17,6 +18,7 @@ import url_launcher_macos
import webview_flutter_wkwebview
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FirebaseAnalyticsPlugin.register(with: registry.registrar(forPlugin: "FirebaseAnalyticsPlugin"))
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))

View File

@@ -297,6 +297,30 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: "direct main"
description:

View File

@@ -14,6 +14,9 @@ dependencies:
# Firebase (Messaging only — Auth dropped in Phase 3.4, self-managed JWT now)
firebase_core: ^3.12.1
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
# until the corresponding OAuth env vars are set on the backend)

121
mitra_app/BUILD_FLAVORS.md Normal file
View 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`) |

View File

@@ -21,7 +21,8 @@ android {
}
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"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
@@ -31,6 +32,30 @@ android {
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 {
release {
// TODO: Add your own signing config for the release build.

View File

@@ -61,6 +61,63 @@
"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"

View File

@@ -1,6 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="Mitra HaloBestie"
android:label="@string/app_name"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">

View File

@@ -1,12 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- Same background as the launcher icon (ic_launcher_background = #FFFFFF
for the mitra full-color logo), so the native launch splash matches. -->
<item android:drawable="@color/ic_launcher_background" />
<!-- Square box (logo.png is 4500x4500) so the glyph isn't distorted. -->
<item
android:width="200dp"
android:height="168dp"
android:height="200dp"
android:gravity="center">
<bitmap
android:gravity="fill"
android:src="@drawable/splash_chat_hebat" />
android:src="@drawable/logo" />
</item>
</layer-list>

View File

@@ -1,12 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- Same background as the launcher icon (ic_launcher_background = #FFFFFF
for the mitra full-color logo), so the native launch splash matches. -->
<item android:drawable="@color/ic_launcher_background" />
<!-- Square box (logo.png is 4500x4500) so the glyph isn't distorted. -->
<item
android:width="200dp"
android:height="168dp"
android:height="200dp"
android:gravity="center">
<bitmap
android:gravity="fill"
android:src="@drawable/splash_chat_hebat" />
android:src="@drawable/logo" />
</item>
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

View File

@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowSplashScreenBackground">@android:color/white</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_chat_hebat</item>
<item name="android:windowSplashScreenBackground">@color/ic_launcher_background</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/logo</item>
</style>
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>

View 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"
}

View 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.

View 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"
}

View File

@@ -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
View 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
View File

@@ -0,0 +1,4 @@
{
"API_BASE_URL": "https://api.halobestie.com",
"FLAVOR": "prod"
}

4
mitra_app/env/staging.json vendored Normal file
View File

@@ -0,0 +1,4 @@
{
"API_BASE_URL": "https://staging-api.halobestie.com",
"FLAVOR": "staging"
}

View 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.

View 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>

View 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.

View 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>

View 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.

View 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>

View 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.

View 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,
),
);
}
}

View File

@@ -214,7 +214,9 @@ class MitraChat extends _$MitraChat {
content: m['content'] as String,
type: m['type'] as String? ?? MessageType.text,
status: m['status'] as String? ?? MessageStatus.sent,
createdAt: DateTime.parse(m['created_at'] as String),
// Server sends UTC (ISO-8601 with Z); render in device-local time so
// bubbles match optimistic sends (DateTime.now()) + the transcript view.
createdAt: DateTime.parse(m['created_at'] as String).toLocal(),
)).toList();
final token = ref.read(authBridgeProvider).accessToken;
@@ -329,7 +331,8 @@ class MitraChat extends _$MitraChat {
content: data['content'] as String,
type: data['message_type'] as String? ?? MessageType.text,
status: MessageStatus.sent,
createdAt: DateTime.parse(data['created_at'] as String),
// UTC from server → device-local for display (see history-load note).
createdAt: DateTime.parse(data['created_at'] as String).toLocal(),
);
state = current.copyWith(messages: [...current.messages, msg]);
markDelivered([msg.id]);

View File

@@ -1,5 +1,9 @@
import 'package:flutter/material.dart';
/// Loading gate shown by the `/splash` route while auth resolves on launch.
/// Visually matches the native Android launch splash (new logo on white), so
/// the user only ever sees one splash with the current icon — no flash of the
/// old `splash_chat_hebat` image.
class SplashScreen extends StatelessWidget {
const SplashScreen({super.key});
@@ -9,7 +13,7 @@ class SplashScreen extends StatelessWidget {
backgroundColor: Colors.white,
body: Center(
child: Image.asset(
'assets/images/splash_chat_hebat.png',
'assets/icons/logo.png',
width: 200,
),
),

Some files were not shown because too many files have changed in this diff Show More