Compare commits

...

7 Commits

Author SHA1 Message Date
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
81 changed files with 2997 additions and 202 deletions

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

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 { defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). // Base application ID. Per-flavor suffixes below produce the final IDs:
// dev -> com.mybestie.dev
// staging -> com.mybestie.staging
// prod -> com.mybestie
applicationId = "com.mybestie" applicationId = "com.mybestie"
// You can update the following values to match your application needs. // You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config. // For more information, see: https://flutter.dev/to/review-gradle-config.
@@ -34,6 +37,34 @@ android {
versionName = flutter.versionName versionName = flutter.versionName
} }
// Build flavors for the three environments. Each flavor:
// - sets its final applicationId (via suffix, except prod)
// - injects an `app_name` string resource consumed by
// AndroidManifest.xml's android:label="@string/app_name"
// - selects its own Firebase config via the matching source set
// (android/app/src/<flavor>/google-services.json)
// NOTE: once these flavors exist, a bare `flutter build apk` (no --flavor)
// fails. All build/install/run commands MUST pass --flavor. See
// BUILD_FLAVORS.md.
flavorDimensions += "env"
productFlavors {
create("dev") {
dimension = "env"
applicationIdSuffix = ".dev"
resValue("string", "app_name", "HaloBestie Dev")
}
create("staging") {
dimension = "env"
applicationIdSuffix = ".staging"
resValue("string", "app_name", "HaloBestie Staging")
}
create("prod") {
dimension = "env"
// No applicationIdSuffix -> final applicationId stays "com.mybestie".
resValue("string", "app_name", "HaloBestie")
}
}
buildTypes { buildTypes {
release { release {
// TODO: Add your own signing config for the release build. // TODO: Add your own signing config for the release build.

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

View File

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

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("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false id("com.android.application") version "8.11.1" apply false
// START: FlutterFire Configuration // START: FlutterFire Configuration
id("com.google.gms.google-services") version("4.3.10") apply false id("com.google.gms.google-services") version("4.4.2") apply false
// END: FlutterFire Configuration // END: FlutterFire Configuration
id("org.jetbrains.kotlin.android") version "2.2.20" apply false id("org.jetbrains.kotlin.android") version "2.2.20" apply false
} }

4
client_app/env/dev.json vendored Normal file
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)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = com.mybestie; PRODUCT_BUNDLE_IDENTIFIER = com.asc.hallobestie;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@@ -513,7 +513,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.mybestie.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = com.asc.hallobestie.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -531,7 +531,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.mybestie.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = com.asc.hallobestie.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -547,7 +547,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.mybestie.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = com.asc.hallobestie.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -679,7 +679,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = com.mybestie; PRODUCT_BUNDLE_IDENTIFIER = com.asc.hallobestie;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -702,7 +702,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = com.mybestie; PRODUCT_BUNDLE_IDENTIFIER = com.asc.hallobestie;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;

View File

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

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 // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$authHash() => r'f0382b1380749cebe8182ad221023926768ebb92'; String _$authHash() => r'76cd43babe5503d35a35c8fd23ba32afbc4c8c2d';
/// See also [Auth]. /// See also [Auth].
@ProviderFor(Auth) @ProviderFor(Auth)

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../core/analytics/analytics_service.dart';
import '../../../core/api/api_client_provider.dart'; import '../../../core/api/api_client_provider.dart';
import '../../../core/auth/auth_notifier.dart'; import '../../../core/auth/auth_notifier.dart';
import '../../../core/theme/halo_tokens.dart'; import '../../../core/theme/halo_tokens.dart';
@@ -35,6 +36,9 @@ class _DisplayNameScreenState extends ConsumerState<DisplayNameScreen> {
final data = next.valueOrNull; final data = next.valueOrNull;
if (data is AuthAnonymousData && !_routedAfterLogin) { if (data is AuthAnonymousData && !_routedAfterLogin) {
_routedAfterLogin = true; _routedAfterLogin = true;
// Anonymous identity established — activation step 6.
// ignore: discarded_futures
ref.read(analyticsProvider).logAuthComplete(AnalyticsUserType.anonymous);
_proceedAfterLogin(); _proceedAfterLogin();
} }
}); });
@@ -82,12 +86,17 @@ class _DisplayNameScreenState extends ConsumerState<DisplayNameScreen> {
return; return;
} }
// ignore: discarded_futures
ref.read(analyticsProvider).logVerifChoiceView();
final choice = await VerifChoiceSheet.show(context); final choice = await VerifChoiceSheet.show(context);
if (!mounted || choice == null) { if (!mounted || choice == null) {
// User dismissed the sheet — let them tap Lanjut again to retry. // User dismissed the sheet — let them tap Lanjut again to retry. No
// select event: the view→select gap is the abandonment signal.
_routedAfterLogin = false; _routedAfterLogin = false;
return; return;
} }
// ignore: discarded_futures
ref.read(analyticsProvider).logVerifChoiceSelect(choice);
if (!mounted) return; if (!mounted) return;
await routeForVerifChoice(context, ref, choice); await routeForVerifChoice(context, ref, choice);
} }

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/analytics/analytics_service.dart';
import '../../../core/api/api_client_provider.dart'; import '../../../core/api/api_client_provider.dart';
import '../../../core/auth/auth_notifier.dart'; import '../../../core/auth/auth_notifier.dart';
import '../../../core/constants.dart'; import '../../../core/constants.dart';
@@ -75,6 +76,14 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
if (_errorMessage != null && mounted) { if (_errorMessage != null && mounted) {
setState(() => _errorMessage = null); setState(() => _errorMessage = null);
} }
// OTP verify resolved to a real identity — activation/repeat step 6.
final data = next.valueOrNull;
if (data is AuthAuthenticatedData || data is AuthNeedsDisplayNameData) {
// ignore: discarded_futures
ref
.read(analyticsProvider)
.logAuthComplete(AnalyticsUserType.verified);
}
} }
}); });
@@ -152,6 +161,8 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
final code = _readCode(); final code = _readCode();
if (code.length == _kOtpLength && !_autoSubmitted && _otpRequestId != null) { if (code.length == _kOtpLength && !_autoSubmitted && _otpRequestId != null) {
_autoSubmitted = true; _autoSubmitted = true;
// ignore: discarded_futures
ref.read(analyticsProvider).logAuthOtpSubmit();
ref.read(authProvider.notifier).verifyOtp(_otpRequestId!, code); ref.read(authProvider.notifier).verifyOtp(_otpRequestId!, code);
} }
} }

View File

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

View File

@@ -1,9 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../core/analytics/analytics_service.dart';
import '../../../core/chat/active_session_notifier.dart'; import '../../../core/chat/active_session_notifier.dart';
import '../../../core/pairing/pairing_notifier.dart'; import '../../../core/pairing/pairing_notifier.dart';
import '../../../core/theme/halo_tokens.dart'; import '../../../core/theme/halo_tokens.dart';
import '../../payment/state/payment_draft_provider.dart';
import '../../../core/theme/widgets/widgets.dart'; import '../../../core/theme/widgets/widgets.dart';
/// Phase 4 Stage 5 — S9 Match-found screen. /// Phase 4 Stage 5 — S9 Match-found screen.
@@ -35,6 +37,15 @@ class _BestieFoundScreenState extends ConsumerState<BestieFoundScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// Funnel step 12 — paired with a mitra. A targeted-mitra draft means the
// repeat funnel; otherwise activation. Fire once on view.
final isRepeat =
ref.read(paymentDraftNotifierProvider).targetedMitraId != null;
// ignore: discarded_futures
ref.read(analyticsProvider).logPairingMatched(
funnel:
isRepeat ? AnalyticsFunnel.repeat : AnalyticsFunnel.activation,
);
ref.listenManual<PairingData>(pairingProvider, (prev, next) { ref.listenManual<PairingData>(pairingProvider, (prev, next) {
if (!mounted) return; if (!mounted) return;
if (next is PairingActiveData) { if (next is PairingActiveData) {

View File

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

View File

@@ -1,7 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../core/analytics/analytics_service.dart';
import '../../../core/pairing/pairing_notifier.dart'; import '../../../core/pairing/pairing_notifier.dart';
import '../../payment/state/payment_draft_provider.dart';
/// Terminal failed-pairing screen. /// Terminal failed-pairing screen.
/// ///
@@ -13,11 +15,30 @@ import '../../../core/pairing/pairing_notifier.dart';
/// Single CTA "Kembali ke beranda" resets the pairing notifier and routes /// Single CTA "Kembali ke beranda" resets the pairing notifier and routes
/// home. PopScope falls back to home for deep-link entry per project memory /// home. PopScope falls back to home for deep-link entry per project memory
/// rule "Deep-link pop fallback". /// rule "Deep-link pop fallback".
class NoBestieScreen extends ConsumerWidget { class NoBestieScreen extends ConsumerStatefulWidget {
const NoBestieScreen({super.key}); const NoBestieScreen({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { ConsumerState<NoBestieScreen> createState() => _NoBestieScreenState();
}
class _NoBestieScreenState extends ConsumerState<NoBestieScreen> {
@override
void initState() {
super.initState();
// Funnel drop-off marker — pairing failed. A targeted-mitra draft means
// the repeat funnel; otherwise activation. Fire once on view.
final isRepeat =
ref.read(paymentDraftNotifierProvider).targetedMitraId != null;
// ignore: discarded_futures
ref.read(analyticsProvider).logPairingNoBestie(
funnel:
isRepeat ? AnalyticsFunnel.repeat : AnalyticsFunnel.activation,
);
}
@override
Widget build(BuildContext context) {
return PopScope( return PopScope(
canPop: true, canPop: true,
onPopInvokedWithResult: (didPop, _) { onPopInvokedWithResult: (didPop, _) {

View File

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

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../core/analytics/analytics_service.dart';
import '../../core/auth/auth_notifier.dart'; import '../../core/auth/auth_notifier.dart';
import '../../core/auth/onboarding_intent_provider.dart'; import '../../core/auth/onboarding_intent_provider.dart';
import '../../core/availability/mitra_availability_notifier.dart'; import '../../core/availability/mitra_availability_notifier.dart';
@@ -81,6 +82,11 @@ class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObse
/// CTA path for SHomeReturning. Returning users get the bestie-choice sheet /// CTA path for SHomeReturning. Returning users get the bestie-choice sheet
/// when they have prior history, otherwise jump to the new-payment shell. /// when they have prior history, otherwise jump to the new-payment shell.
Future<void> _onCurhatBestieBaruPressed(BuildContext context) async { Future<void> _onCurhatBestieBaruPressed(BuildContext context) async {
// Returning user starting a fresh curhat (repeat funnel). The
// bestie_reselect sub-event fires later from the history list if they pick
// a known bestie; this marks the top of the repeat funnel.
// ignore: discarded_futures
ref.read(analyticsProvider).logCurhatRepeatStart();
bool hasHistory; bool hasHistory;
try { try {
hasHistory = await ref.read(bestieHistoryHasItemsProvider.future); hasHistory = await ref.read(bestieHistoryHasItemsProvider.future);
@@ -89,6 +95,8 @@ class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObse
} }
if (!context.mounted) return; if (!context.mounted) return;
if (hasHistory) { if (hasHistory) {
// ignore: discarded_futures
ref.read(analyticsProvider).logBestieChoiceView();
await BestieChoiceSheet.show(context); await BestieChoiceSheet.show(context);
return; return;
} }
@@ -101,6 +109,9 @@ class _HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObse
/// (the call-sign check); display_name_screen kicks off `loginAnonymous` /// (the call-sign check); display_name_screen kicks off `loginAnonymous`
/// and pushes into the verif-choice sheet. /// and pushes into the verif-choice sheet.
void _onAkuMauCurhatPressed(BuildContext context) { void _onAkuMauCurhatPressed(BuildContext context) {
// Top of the activation funnel — fresh user tapping the primary CTA.
// ignore: discarded_futures
ref.read(analyticsProvider).logCurhatStart(entryPoint: 'home_primary');
context.push('/auth/display-name'); context.push('/auth/display-name');
} }
@@ -172,7 +183,7 @@ class _SHome1stView extends ConsumerWidget {
const _GreetingSubtitle(), const _GreetingSubtitle(),
const SizedBox(height: 32), const SizedBox(height: 32),
_PrimaryCTA( _PrimaryCTA(
label: 'aku mau curhat', label: 'Aku Mau Curhat',
enabled: mitraAvailable, enabled: mitraAvailable,
onPressed: onCTA, onPressed: onCTA,
), ),
@@ -395,7 +406,7 @@ class _SHomeReturningView extends ConsumerWidget {
activeSessionAsync.when( activeSessionAsync.when(
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: (_, __) => _PrimaryCTA( error: (_, __) => _PrimaryCTA(
label: 'curhat sama bestie baru', label: 'Aku Mau Curhat',
enabled: mitraAvailable, enabled: mitraAvailable,
onPressed: onCTA, onPressed: onCTA,
), ),
@@ -415,7 +426,7 @@ class _SHomeReturningView extends ConsumerWidget {
); );
} }
return _PrimaryCTA( return _PrimaryCTA(
label: 'curhat sama bestie baru', label: 'Aku Mau Curhat',
enabled: mitraAvailable, enabled: mitraAvailable,
onPressed: onCTA, onPressed: onCTA,
); );

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../core/analytics/analytics_service.dart';
import '../../../core/constants.dart'; import '../../../core/constants.dart';
import '../../../core/theme/halo_tokens.dart'; import '../../../core/theme/halo_tokens.dart';
import '../../../core/theme/widgets/widgets.dart'; import '../../../core/theme/widgets/widgets.dart';
@@ -132,6 +133,13 @@ class BestieHistoryListScreen extends ConsumerWidget {
return; return;
} }
if (item.mitraId == null) return; if (item.mitraId == null) return;
// Repeat funnel: user re-selected a known bestie. mitra_ref
// is opaque (hashed) — never the raw mitra id, per the
// no-PII / opaque-mitra-identity rule.
// ignore: discarded_futures
ref.read(analyticsProvider).logBestieReselect(
mitraRef: item.mitraId!.hashCode.toString(),
);
// Stamp the targeted mitra onto the payment draft; the // Stamp the targeted mitra onto the payment draft; the
// multi-screen payment flow (entry → method → waiting → // multi-screen payment flow (entry → method → waiting →
// notif-gate → searching) reads it back to fire the // notif-gate → searching) reads it back to fire the

View File

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

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../core/analytics/analytics_service.dart';
import '../../../core/theme/halo_tokens.dart'; import '../../../core/theme/halo_tokens.dart';
import '../../../core/theme/widgets/widgets.dart'; import '../../../core/theme/widgets/widgets.dart';
import '../usp_seen_provider.dart'; import '../usp_seen_provider.dart';
@@ -11,11 +12,30 @@ import '../usp_seen_provider.dart';
/// ///
/// `verified` ➞ USP → OTP (`/auth/register`). /// `verified` ➞ USP → OTP (`/auth/register`).
/// `anonymous` ➞ USP → `/payment/method-pick`. /// `anonymous` ➞ USP → `/payment/method-pick`.
class UspScreen extends ConsumerWidget { class UspScreen extends ConsumerStatefulWidget {
final bool verified; final bool verified;
const UspScreen({super.key, required this.verified}); const UspScreen({super.key, required this.verified});
@override
ConsumerState<UspScreen> createState() => _UspScreenState();
}
class _UspScreenState extends ConsumerState<UspScreen> {
@override
void initState() {
super.initState();
// Activation funnel step 7 — fire on view (not teardown). One-shot:
// initState runs once per screen instance.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
// ignore: discarded_futures
ref
.read(analyticsProvider)
.logOnboardingUspView(verified: widget.verified);
});
}
static const _cards = [ static const _cards = [
_UspCard( _UspCard(
icon: Icons.bolt_outlined, icon: Icons.bolt_outlined,
@@ -40,7 +60,7 @@ class UspScreen extends ConsumerWidget {
]; ];
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Padding( title: const Padding(
@@ -94,7 +114,7 @@ class UspScreen extends ConsumerWidget {
HaloButton( HaloButton(
label: 'aku ngerti, lanjut', label: 'aku ngerti, lanjut',
fullWidth: true, fullWidth: true,
onPressed: () => _onContinue(context, ref), onPressed: () => _onContinue(context),
), ),
], ],
), ),
@@ -103,12 +123,12 @@ class UspScreen extends ConsumerWidget {
); );
} }
Future<void> _onContinue(BuildContext context, WidgetRef ref) async { Future<void> _onContinue(BuildContext context) async {
// Persist the local + server flag before leaving — next time the user // Persist the local + server flag before leaving — next time the user
// hits VerifChoice, this screen is skipped. // hits VerifChoice, this screen is skipped.
await ref.read(uspSeenProvider.notifier).markSeen(); await ref.read(uspSeenProvider.notifier).markSeen();
if (!context.mounted) return; if (!context.mounted) return;
if (verified) { if (widget.verified) {
context.push('/auth/register'); context.push('/auth/register');
} else { } else {
context.push('/payment/method-pick'); context.push('/payment/method-pick');

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../core/analytics/analytics_service.dart';
import '../../../core/auth/onboarding_intent_provider.dart'; import '../../../core/auth/onboarding_intent_provider.dart';
import '../../../core/chat/chat_opening_provider.dart'; import '../../../core/chat/chat_opening_provider.dart';
import '../../../core/theme/halo_tokens.dart'; import '../../../core/theme/halo_tokens.dart';
@@ -38,6 +39,18 @@ class _PaymentEntryScreenState extends ConsumerState<PaymentEntryScreen> {
// inherit a stale onboarding intent. // inherit a stale onboarding intent.
ref.read(onboardingIntentProvider.notifier).state = ref.read(onboardingIntentProvider.notifier).state =
OnboardingIntent.recover; OnboardingIntent.recover;
// Funnel step 8 — payment entry. A targeted mitra (set just before this
// screen by the bestie-history list) marks the repeat funnel; otherwise
// it's activation. resetExceptTarget() above preserves that flag.
final isRepeat =
ref.read(paymentDraftNotifierProvider).targetedMitraId != null;
// ignore: discarded_futures
ref.read(analyticsProvider).logPaymentView(
funnel:
isRepeat ? AnalyticsFunnel.repeat : AnalyticsFunnel.activation,
isRepeat: isRepeat,
);
}); });
} }

View File

@@ -2,6 +2,7 @@ import 'package:dio/dio.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../core/analytics/analytics_service.dart';
import '../../../core/api/api_client_provider.dart'; import '../../../core/api/api_client_provider.dart';
import '../../../core/constants.dart'; import '../../../core/constants.dart';
import '../../../core/theme/halo_tokens.dart'; import '../../../core/theme/halo_tokens.dart';
@@ -53,7 +54,21 @@ class _PaymentMethodScreenState extends ConsumerState<PaymentMethodScreen> {
_error = null; _error = null;
}); });
final api = ref.read(apiClientProvider); final api = ref.read(apiClientProvider);
final analytics = ref.read(analyticsProvider);
try { try {
// ⭐ Capture GA4 stitching identifiers BEFORE the POST so the backend can
// store them in product_metadata and replay them in the server-fired
// payment_confirmed (Measurement Protocol). The backend currently
// ignores unknown body fields — intentional; we send now, stitch later.
final appInstanceId = await analytics.appInstanceId();
final gaSessionId = await analytics.sessionId();
if (!mounted) return;
final analyticsIds = <String, dynamic>{
if (appInstanceId != null) 'app_instance_id': appInstanceId,
if (gaSessionId != null) 'ga_session_id': gaSessionId,
};
final body = <String, dynamic>{ final body = <String, dynamic>{
'mode': draft.mode.value, 'mode': draft.mode.value,
'duration_minutes': draft.durationMinutes, 'duration_minutes': draft.durationMinutes,
@@ -61,11 +76,27 @@ class _PaymentMethodScreenState extends ConsumerState<PaymentMethodScreen> {
'is_first_session_discount': draft.isFirstSessionDiscount, 'is_first_session_discount': draft.isFirstSessionDiscount,
'method': _selectedCode, 'method': _selectedCode,
if (draft.targetedMitraId != null) 'targeted_mitra_id': draft.targetedMitraId, if (draft.targetedMitraId != null) 'targeted_mitra_id': draft.targetedMitraId,
if (analyticsIds.isNotEmpty) 'analytics': analyticsIds,
}; };
final response = await api.post('/api/client/payment-requests/', data: body); final response = await api.post('/api/client/payment-requests/', data: body);
final data = response['data'] as Map<String, dynamic>; final data = response['data'] as Map<String, dynamic>;
final paymentId = data['id'] as String; final paymentId = data['id'] as String;
ref.read(paymentDraftNotifierProvider.notifier).setPaymentId(paymentId); ref.read(paymentDraftNotifierProvider.notifier).setPaymentId(paymentId);
// ⭐ payment_started fires AFTER the id is known. A targeted mitra means
// the returning/repeat funnel; otherwise activation.
final isRepeat = draft.targetedMitraId != null;
// ignore: discarded_futures
analytics.logPaymentStarted(
paymentRequestId: paymentId,
amount: draft.priceIDR!,
method: _selectedCode!,
funnel: isRepeat ? AnalyticsFunnel.repeat : AnalyticsFunnel.activation,
isRepeat: isRepeat,
productType: draft.mode.value,
durationMinutes: draft.durationMinutes!,
);
if (!mounted) return; if (!mounted) return;
context.push('/payment/waiting/$paymentId'); context.push('/payment/waiting/$paymentId');
} on DioException catch (e) { } on DioException catch (e) {
@@ -216,10 +247,20 @@ class _PaymentMethodScreenState extends ConsumerState<PaymentMethodScreen> {
_expandedGroupIds.remove(g.id); _expandedGroupIds.remove(g.id);
} }
}), }),
onSelect: (code) => setState(() { onSelect: (code) {
// Funnel step 9 — method chosen. Fire once per pick
// (not on every rebuild).
if (code != _selectedCode) {
// ignore: discarded_futures
ref
.read(analyticsProvider)
.logPaymentMethodSelect(method: code);
}
setState(() {
_selectedCode = code; _selectedCode = code;
_error = null; _error = null;
}), });
},
); );
}).toList(), }).toList(),
); );

View File

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

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 // ignore_for_file: type=lint
import 'package:firebase_core/firebase_core.dart' show FirebaseOptions; import 'package:firebase_core/firebase_core.dart' show FirebaseOptions;
import 'package:flutter/foundation.dart' import 'package:flutter/foundation.dart'
show defaultTargetPlatform, kIsWeb, TargetPlatform; show defaultTargetPlatform, kIsWeb, TargetPlatform;
/// Default [FirebaseOptions] for use with your Firebase apps. /// [FirebaseOptions] for the DEV environment (project halobestie-clone-dev).
/// class DevFirebaseOptions {
/// Example:
/// ```dart
/// import 'firebase_options.dart';
/// // ...
/// await Firebase.initializeApp(
/// options: DefaultFirebaseOptions.currentPlatform,
/// );
/// ```
class DefaultFirebaseOptions {
static FirebaseOptions get currentPlatform { static FirebaseOptions get currentPlatform {
if (kIsWeb) { if (kIsWeb) {
return web; throw UnsupportedError(
'DefaultFirebaseOptions have not been configured for web - '
'you can reconfigure this by running the FlutterFire CLI again.',
);
} }
switch (defaultTargetPlatform) { switch (defaultTargetPlatform) {
case TargetPlatform.android: case TargetPlatform.android:
@@ -48,7 +43,7 @@ class DefaultFirebaseOptions {
static const FirebaseOptions android = FirebaseOptions( static const FirebaseOptions android = FirebaseOptions(
apiKey: 'AIzaSyDFWlLSWytqwI7LSdUbVrO7J5De9L2LV2U', apiKey: 'AIzaSyDFWlLSWytqwI7LSdUbVrO7J5De9L2LV2U',
appId: '1:1068156046511:android:f30784f6b0423131b8185a', appId: '1:1068156046511:android:1f589ed358ccdad0b8185a',
messagingSenderId: '1068156046511', messagingSenderId: '1068156046511',
projectId: 'halobestie-clone-dev', projectId: 'halobestie-clone-dev',
storageBucket: 'halobestie-clone-dev.firebasestorage.app', storageBucket: 'halobestie-clone-dev.firebasestorage.app',
@@ -56,21 +51,10 @@ class DefaultFirebaseOptions {
static const FirebaseOptions ios = FirebaseOptions( static const FirebaseOptions ios = FirebaseOptions(
apiKey: 'AIzaSyAQnB5hbj0T5tE4JQZQ9Tx6Whp_u15obMI', apiKey: 'AIzaSyAQnB5hbj0T5tE4JQZQ9Tx6Whp_u15obMI',
appId: '1:1068156046511:ios:b781f67a57d6db7bb8185a', appId: '1:1068156046511:ios:bc9098ffc2c2913ab8185a',
messagingSenderId: '1068156046511', messagingSenderId: '1068156046511',
projectId: 'halobestie-clone-dev', projectId: 'halobestie-clone-dev',
storageBucket: 'halobestie-clone-dev.firebasestorage.app', storageBucket: 'halobestie-clone-dev.firebasestorage.app',
iosBundleId: 'com.mybestie.mitra', iosBundleId: 'com.asc.hallobestie.dev',
); );
static const FirebaseOptions web = FirebaseOptions(
apiKey: 'AIzaSyAvDQp6xLOZHSwhaj9Zk3DjcMvQyX0Y7Oc',
appId: '1:1068156046511:web:15b173b38aa563ceb8185a',
messagingSenderId: '1068156046511',
projectId: 'halobestie-clone-dev',
authDomain: 'halobestie-clone-dev.firebaseapp.com',
storageBucket: 'halobestie-clone-dev.firebasestorage.app',
measurementId: 'G-FK3V0LB3TT',
);
} }

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 'dart:async';
import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'core/analytics/analytics_service.dart';
import 'core/api/api_client_provider.dart'; import 'core/api/api_client_provider.dart';
import 'core/auth/auth_notifier.dart'; import 'core/auth/auth_notifier.dart';
import 'core/auth/auth_providers_provider.dart'; import 'core/auth/auth_providers_provider.dart';
@@ -13,10 +15,23 @@ import 'core/chat/chat_notifier.dart';
import 'core/notifications/notification_service.dart'; import 'core/notifications/notification_service.dart';
import 'core/pairing/pairing_notifier.dart'; import 'core/pairing/pairing_notifier.dart';
import 'core/theme/halo_theme.dart'; import 'core/theme/halo_theme.dart';
import 'firebase_options.dart'; import 'firebase/firebase_options_dev.dart';
import 'router.dart'; import 'router.dart';
void main() async { /// Shared app bootstrap, parameterised per build flavor.
///
/// The flavor entrypoints (`main_dev.dart`, `main_staging.dart`,
/// `main_prod.dart`) each call this with their environment's
/// [FirebaseOptions] and a [flavor] tag. The bare [main] below delegates to
/// dev so a plain `flutter run` (no `-t`) still launches the dev environment.
///
/// `flavor` is currently informational (kept on hand for future flavor-gated
/// behaviour / analytics tagging); the API base URL is supplied separately via
/// `--dart-define-from-file=env/<flavor>.json` (see BUILD_FLAVORS.md).
Future<void> bootstrap({
required FirebaseOptions firebaseOptions,
required String flavor,
}) async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
// Pre-warm flutter_secure_storage. The first call triggers AndroidX // Pre-warm flutter_secure_storage. The first call triggers AndroidX
@@ -26,7 +41,13 @@ void main() async {
// splash instead of paying it on the user's first interaction. // splash instead of paying it on the user's first interaction.
unawaited(TokenStorage().readRefreshToken()); unawaited(TokenStorage().readRefreshToken());
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); await Firebase.initializeApp(options: firebaseOptions);
// Enable GA4 collection. Fire-and-forget so it never adds to cold-start
// latency; the SDK queues events until collection is on.
unawaited(
FirebaseAnalytics.instance.setAnalyticsCollectionEnabled(true),
);
final messaging = FirebaseMessaging.instance; final messaging = FirebaseMessaging.instance;
await messaging.requestPermission(); await messaging.requestPermission();
@@ -34,6 +55,16 @@ void main() async {
runApp(const ProviderScope(child: App())); runApp(const ProviderScope(child: App()));
} }
void main() async {
// Bare `flutter run` (no `-t lib/main_<flavor>.dart`) defaults to dev so
// local development works out of the box. Build-flavor APKs use the
// flavor-specific entrypoints instead.
await bootstrap(
firebaseOptions: DevFirebaseOptions.currentPlatform,
flavor: 'dev',
);
}
class App extends ConsumerStatefulWidget { class App extends ConsumerStatefulWidget {
const App({super.key}); const App({super.key});
@@ -131,9 +162,41 @@ class _AppState extends ConsumerState<App> with WidgetsBindingObserver {
}); });
} }
// Tracks the last user_id pushed to GA4 so we don't re-issue identical
// setUserId/user-property calls on every transient auth emission.
String? _analyticsUserId;
/// Mirror auth state into GA4 identity (§4): opaque customer UUID as
/// `user_id` + `user_type` property. Re-set on identity upgrade
/// (anon→verified) so the same user continues. Never sets phone/name.
void _syncAnalyticsIdentity(AuthData? data) {
final analytics = ref.read(analyticsProvider);
final (String? customerId, AnalyticsUserType? userType) = switch (data) {
AuthAnonymousData d => (d.customerId, AnalyticsUserType.anonymous),
AuthForceRegisterData d => (d.customerId, AnalyticsUserType.anonymous),
AuthAuthenticatedData d => (
d.profile['id'] as String?,
AnalyticsUserType.verified,
),
AuthNeedsDisplayNameData d => (
d.profile['id'] as String?,
AnalyticsUserType.verified,
),
_ => (null, null),
};
if (customerId == _analyticsUserId) return;
_analyticsUserId = customerId;
// ignore: discarded_futures
analytics.setUserId(customerId);
if (userType != null) {
// ignore: discarded_futures
analytics.setUserType(userType);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// FCM registration on auth. // FCM registration + analytics identity on auth.
ref.listen(authProvider, (prev, next) { ref.listen(authProvider, (prev, next) {
final data = next.valueOrNull; final data = next.valueOrNull;
if (data is AuthAuthenticatedData || data is AuthAnonymousData) { if (data is AuthAuthenticatedData || data is AuthAnonymousData) {
@@ -142,6 +205,7 @@ class _AppState extends ConsumerState<App> with WidgetsBindingObserver {
// Logged out (or initial) — ensure the chat WS is closed. // Logged out (or initial) — ensure the chat WS is closed.
ref.read(chatProvider.notifier).disconnect(); ref.read(chatProvider.notifier).disconnect();
} }
_syncAnalyticsIdentity(data);
}); });
// Global chat WebSocket lifecycle: connect whenever the user has an // Global chat WebSocket lifecycle: connect whenever the user has an

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/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'core/analytics/analytics_service.dart';
import 'core/auth/auth_notifier.dart'; import 'core/auth/auth_notifier.dart';
import 'core/auth/onboarding_intent_provider.dart'; import 'core/auth/onboarding_intent_provider.dart';
import 'features/auth/screens/display_name_screen.dart'; import 'features/auth/screens/display_name_screen.dart';
@@ -54,12 +56,62 @@ class RouterNotifier extends ChangeNotifier {
final routerProvider = Provider<GoRouter>((ref) => buildRouter(ref)); final routerProvider = Provider<GoRouter>((ref) => buildRouter(ref));
/// Maps a GoRoute path template (`Route.settings.name`) to a stable
/// `screen_name`. Keyed on the *template* (e.g. `/chat/session/:sessionId`)
/// so path params are never part of the logged name. Routes absent here are
/// dropped (return null → observer skips the screen_view).
const _screenNameByRoute = <String, AnalyticsScreen>{
'/splash': AnalyticsScreen.splash,
'/auth/display-name': AnalyticsScreen.authDisplayName,
'/auth/register': AnalyticsScreen.authRegister,
'/auth/otp': AnalyticsScreen.authOtp,
'/auth/set-name': AnalyticsScreen.authSetName,
'/auth/force-register': AnalyticsScreen.authForceRegister,
'/onboarding/verif/usp': AnalyticsScreen.onboardingUspVerified,
'/onboarding/anon/usp': AnalyticsScreen.onboardingUspAnon,
'/onboarding/notif-gate': AnalyticsScreen.onboardingNotifGate,
'/home': AnalyticsScreen.home,
'/profile': AnalyticsScreen.profile,
'/payment/entry': AnalyticsScreen.paymentEntry,
'/payment/discount-paywall': AnalyticsScreen.paymentDiscountPaywall,
'/payment/method-pick': AnalyticsScreen.curhatModePick,
'/payment/duration-pick': AnalyticsScreen.paymentDurationPick,
'/payment/method': AnalyticsScreen.paymentMethod,
'/payment/waiting/:paymentId': AnalyticsScreen.paymentWaiting,
'/payment/expired/:paymentId': AnalyticsScreen.paymentExpired,
'/chat/searching': AnalyticsScreen.chatSearching,
'/chat/found': AnalyticsScreen.chatFound,
'/chat/no-bestie': AnalyticsScreen.chatNoBestie,
'/chat/waiting-targeted/:mitraId': AnalyticsScreen.chatWaitingTargeted,
'/chat/session/:sessionId': AnalyticsScreen.chatSession,
'/chat/thank-you': AnalyticsScreen.chatThankYou,
'/chat/aktif': AnalyticsScreen.chatTabAktif,
'/chat/pembayaran': AnalyticsScreen.chatTabPembayaran,
'/chat/selesai': AnalyticsScreen.chatTabSelesai,
'/chat/transcript/:sessionId': AnalyticsScreen.chatTranscript,
'/bestie/history': AnalyticsScreen.bestieHistory,
};
/// `nameExtractor` for [FirebaseAnalyticsObserver]. GoRouter sets
/// `Route.settings.name` to the route's path template, so this strips path
/// params (`:sessionId` etc.) by construction.
String? _screenNameFor(RouteSettings settings) {
final name = settings.name;
if (name == null) return null;
return _screenNameByRoute[name]?.value;
}
GoRouter buildRouter(Ref ref) { GoRouter buildRouter(Ref ref) {
final notifier = RouterNotifier(ref); final notifier = RouterNotifier(ref);
final analyticsObserver = FirebaseAnalyticsObserver(
analytics: FirebaseAnalytics.instance,
nameExtractor: _screenNameFor,
);
return GoRouter( return GoRouter(
initialLocation: kThemePreviewEnabled ? '/_theme_preview' : '/splash', initialLocation: kThemePreviewEnabled ? '/_theme_preview' : '/splash',
refreshListenable: notifier, refreshListenable: notifier,
observers: [analyticsObserver],
redirect: (context, state) { redirect: (context, state) {
// Theme preview is dev-only and intentionally bypasses auth + onboarding // Theme preview is dev-only and intentionally bypasses auth + onboarding
// gates so it can be opened on any device build. // gates so it can be opened on any device build.

View File

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

View File

@@ -297,6 +297,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.1" version: "7.0.1"
firebase_analytics:
dependency: "direct main"
description:
name: firebase_analytics
sha256: "4f85b161772e1d54a66893ef131c0a44bd9e552efa78b33d5f4f60d2caa5c8a3"
url: "https://pub.dev"
source: hosted
version: "11.6.0"
firebase_analytics_platform_interface:
dependency: transitive
description:
name: firebase_analytics_platform_interface
sha256: a44b6d1155ed5cae7641e3de7163111cfd9f6f6c954ca916dc6a3bdfa86bf845
url: "https://pub.dev"
source: hosted
version: "4.4.3"
firebase_analytics_web:
dependency: transitive
description:
name: firebase_analytics_web
sha256: c7d1ed1f86ae64215757518af5576ff88341c8ce5741988c05cc3b2e07b0b273
url: "https://pub.dev"
source: hosted
version: "0.5.10+16"
firebase_core: firebase_core:
dependency: "direct main" dependency: "direct main"
description: description:

View File

@@ -14,6 +14,9 @@ dependencies:
# Firebase (Messaging only — Auth dropped in Phase 3.4, self-managed JWT now) # Firebase (Messaging only — Auth dropped in Phase 3.4, self-managed JWT now)
firebase_core: ^3.12.1 firebase_core: ^3.12.1
firebase_messaging: ^15.2.5 firebase_messaging: ^15.2.5
# GA4 funnel analytics (no PII — see requirement/analytics-funnel-plan.md).
# ^11.x is the firebase_core ^3.x-compatible major.
firebase_analytics: ^11.4.4
# Social login (kept — buttons gated server-side via /api/shared/auth-providers # Social login (kept — buttons gated server-side via /api/shared/auth-providers
# until the corresponding OAuth env vars are set on the backend) # until the corresponding OAuth env vars are set on the backend)

121
mitra_app/BUILD_FLAVORS.md Normal file
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 { defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). // Base Application ID. Per-flavor suffixes are applied below in
// productFlavors (dev → .dev, staging → .staging, prod → no suffix).
applicationId = "com.mybestie.mitra" applicationId = "com.mybestie.mitra"
// You can update the following values to match your application needs. // You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config. // For more information, see: https://flutter.dev/to/review-gradle-config.
@@ -31,6 +32,30 @@ android {
versionName = flutter.versionName versionName = flutter.versionName
} }
// Build flavors: dev / staging / prod. Each gets its own applicationId
// (so all three can be installed side-by-side) and its own app_name string
// resource (consumed by AndroidManifest's android:label="@string/app_name").
// A bare `flutter build`/`flutter run` WITHOUT --flavor now fails — every
// command must pass --flavor and the matching -t entrypoint.
flavorDimensions += "env"
productFlavors {
create("dev") {
dimension = "env"
applicationIdSuffix = ".dev"
resValue("string", "app_name", "Mitra HaloBestie Dev")
}
create("staging") {
dimension = "env"
applicationIdSuffix = ".staging"
resValue("string", "app_name", "Mitra HaloBestie Staging")
}
create("prod") {
dimension = "env"
// No applicationIdSuffix — prod keeps the base com.mybestie.mitra.
resValue("string", "app_name", "Mitra HaloBestie")
}
}
buildTypes { buildTypes {
release { release {
// TODO: Add your own signing config for the release build. // TODO: Add your own signing config for the release build.

View File

@@ -61,6 +61,63 @@
"other_platform_oauth_client": [] "other_platform_oauth_client": []
} }
} }
},
{
"client_info": {
"mobilesdk_app_id": "1:1068156046511:android:1f589ed358ccdad0b8185a",
"android_client_info": {
"package_name": "com.mybestie.dev"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyDFWlLSWytqwI7LSdUbVrO7J5De9L2LV2U"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:1068156046511:android:8a873c8b7e64410ab8185a",
"android_client_info": {
"package_name": "com.mybestie.mitra"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyDFWlLSWytqwI7LSdUbVrO7J5De9L2LV2U"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:1068156046511:android:f527c763dea3dc36b8185a",
"android_client_info": {
"package_name": "com.mybestie.mitra.dev"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyDFWlLSWytqwI7LSdUbVrO7J5De9L2LV2U"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
} }
], ],
"configuration_version": "1" "configuration_version": "1"

View File

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

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

@@ -1,19 +1,11 @@
// File generated by FlutterFire CLI. // File generated by FlutterFire CLI (regenerated from the registered
// dev Firebase apps project halobestie-clone-dev).
// ignore_for_file: type=lint // ignore_for_file: type=lint
import 'package:firebase_core/firebase_core.dart' show FirebaseOptions; import 'package:firebase_core/firebase_core.dart' show FirebaseOptions;
import 'package:flutter/foundation.dart' import 'package:flutter/foundation.dart'
show defaultTargetPlatform, kIsWeb, TargetPlatform; show defaultTargetPlatform, kIsWeb, TargetPlatform;
/// Default [FirebaseOptions] for use with your Firebase apps. /// [FirebaseOptions] for the DEV environment (project halobestie-clone-dev).
///
/// Example:
/// ```dart
/// import 'firebase_options.dart';
/// // ...
/// await Firebase.initializeApp(
/// options: DefaultFirebaseOptions.currentPlatform,
/// );
/// ```
class DefaultFirebaseOptions { class DefaultFirebaseOptions {
static FirebaseOptions get currentPlatform { static FirebaseOptions get currentPlatform {
if (kIsWeb) { if (kIsWeb) {
@@ -51,7 +43,7 @@ class DefaultFirebaseOptions {
static const FirebaseOptions android = FirebaseOptions( static const FirebaseOptions android = FirebaseOptions(
apiKey: 'AIzaSyDFWlLSWytqwI7LSdUbVrO7J5De9L2LV2U', apiKey: 'AIzaSyDFWlLSWytqwI7LSdUbVrO7J5De9L2LV2U',
appId: '1:1068156046511:android:ba6e699216de1c50b8185a', appId: '1:1068156046511:android:f527c763dea3dc36b8185a',
messagingSenderId: '1068156046511', messagingSenderId: '1068156046511',
projectId: 'halobestie-clone-dev', projectId: 'halobestie-clone-dev',
storageBucket: 'halobestie-clone-dev.firebasestorage.app', storageBucket: 'halobestie-clone-dev.firebasestorage.app',
@@ -59,10 +51,10 @@ class DefaultFirebaseOptions {
static const FirebaseOptions ios = FirebaseOptions( static const FirebaseOptions ios = FirebaseOptions(
apiKey: 'AIzaSyAQnB5hbj0T5tE4JQZQ9Tx6Whp_u15obMI', apiKey: 'AIzaSyAQnB5hbj0T5tE4JQZQ9Tx6Whp_u15obMI',
appId: '1:1068156046511:ios:c7786cedb9101d34b8185a', appId: '1:1068156046511:ios:907b28451e22981db8185a',
messagingSenderId: '1068156046511', messagingSenderId: '1068156046511',
projectId: 'halobestie-clone-dev', projectId: 'halobestie-clone-dev',
storageBucket: 'halobestie-clone-dev.firebasestorage.app', storageBucket: 'halobestie-clone-dev.firebasestorage.app',
iosBundleId: 'com.mybestie', iosBundleId: 'com.mybestie.mitra.dev',
); );
} }

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 DefaultFirebaseOptions {
static FirebaseOptions get currentPlatform {
if (kIsWeb) {
throw UnsupportedError(
'DefaultFirebaseOptions have not been configured for web - '
'you can reconfigure this by running the FlutterFire CLI again.',
);
}
switch (defaultTargetPlatform) {
case TargetPlatform.android:
return android;
case TargetPlatform.iOS:
return ios;
case TargetPlatform.macOS:
throw UnsupportedError(
'DefaultFirebaseOptions have not been configured for macos - '
'you can reconfigure this by running the FlutterFire CLI again.',
);
case TargetPlatform.windows:
throw UnsupportedError(
'DefaultFirebaseOptions have not been configured for windows - '
'you can reconfigure this by running the FlutterFire CLI again.',
);
case TargetPlatform.linux:
throw UnsupportedError(
'DefaultFirebaseOptions have not been configured for linux - '
'you can reconfigure this by running the FlutterFire CLI again.',
);
default:
throw UnsupportedError(
'DefaultFirebaseOptions are not supported for this platform.',
);
}
}
static const FirebaseOptions android = FirebaseOptions(
apiKey: 'AIzaSyAxAp8hcXO-P7HwwVsS3vFe0OX5ZkIyyWI',
appId: '1:953866659887:android:a4b99d675b0b0315183eda',
messagingSenderId: '953866659887',
projectId: 'my-bestie-production',
storageBucket: 'my-bestie-production.firebasestorage.app',
);
static const FirebaseOptions ios = FirebaseOptions(
apiKey: 'AIzaSyA8guPSD87eDLeCsH6jVd1n2_SI4_MaGNE',
appId: '1:953866659887:ios:cd8dd704842f3489183eda',
messagingSenderId: '953866659887',
projectId: 'my-bestie-production',
storageBucket: 'my-bestie-production.firebasestorage.app',
iosClientId: '953866659887-i0atpahlpqt6r9id17hjeirt5j1uqu8k.apps.googleusercontent.com',
iosBundleId: 'com.mybestie.mitra',
);
}

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 DefaultFirebaseOptions {
static FirebaseOptions get currentPlatform {
if (kIsWeb) {
throw UnsupportedError(
'DefaultFirebaseOptions have not been configured for web - '
'you can reconfigure this by running the FlutterFire CLI again.',
);
}
switch (defaultTargetPlatform) {
case TargetPlatform.android:
return android;
case TargetPlatform.iOS:
return ios;
case TargetPlatform.macOS:
throw UnsupportedError(
'DefaultFirebaseOptions have not been configured for macos - '
'you can reconfigure this by running the FlutterFire CLI again.',
);
case TargetPlatform.windows:
throw UnsupportedError(
'DefaultFirebaseOptions have not been configured for windows - '
'you can reconfigure this by running the FlutterFire CLI again.',
);
case TargetPlatform.linux:
throw UnsupportedError(
'DefaultFirebaseOptions have not been configured for linux - '
'you can reconfigure this by running the FlutterFire CLI again.',
);
default:
throw UnsupportedError(
'DefaultFirebaseOptions are not supported for this platform.',
);
}
}
static const FirebaseOptions android = FirebaseOptions(
apiKey: 'AIzaSyAOPkPJkHXLFzo9ICOHyjee2Vn_EUqt1Pc',
appId: '1:650461407929:android:7571ae8d5036de5d504968',
messagingSenderId: '650461407929',
projectId: 'my-bestie-876ec',
storageBucket: 'my-bestie-876ec.firebasestorage.app',
);
static const FirebaseOptions ios = FirebaseOptions(
apiKey: 'AIzaSyC_BewS88iaNsc9QdwsPzkV0sf9wUs4i_4',
appId: '1:650461407929:ios:b273bda6ad4045ca504968',
messagingSenderId: '650461407929',
projectId: 'my-bestie-876ec',
storageBucket: 'my-bestie-876ec.firebasestorage.app',
iosClientId: '650461407929-kuc6m53nehsa677geu57f3fmn9ql6lbg.apps.googleusercontent.com',
iosBundleId: 'com.mybestie.mitra.staging',
);
}

View File

@@ -1,120 +1,15 @@
import 'package:firebase_core/firebase_core.dart'; import 'bootstrap.dart';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'firebase/firebase_options_dev.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'core/api/api_client_provider.dart';
import 'core/auth/auth_notifier.dart';
import 'core/chat/mitra_chat_notifier.dart';
import 'core/status/status_notifier.dart';
import 'core/chat/chat_request_notifier.dart';
import 'core/chat/widgets/chat_request_overlay.dart';
import 'core/notifications/notification_service.dart';
import 'core/theme/halo_theme.dart';
import 'firebase_options.dart';
import 'router.dart';
void main() async { /// Default entrypoint — delegates to the DEV flavor so a bare `flutter run`
WidgetsFlutterBinding.ensureInitialized(); /// (without -t) still works during local development. The `App` widget and the
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); /// shared startup logic now live in [bootstrap].
///
final messaging = FirebaseMessaging.instance; /// For an explicit flavor, use the dedicated entrypoints instead:
await messaging.requestPermission(); /// - lib/main_dev.dart (flutter run --flavor dev -t lib/main_dev.dart --dart-define-from-file=env/dev.json)
/// - lib/main_staging.dart (flutter run --flavor staging -t lib/main_staging.dart --dart-define-from-file=env/staging.json)
runApp(const ProviderScope(child: App())); /// - lib/main_prod.dart (flutter run --flavor prod -t lib/main_prod.dart --dart-define-from-file=env/prod.json)
} Future<void> main() => bootstrap(
firebaseOptions: DefaultFirebaseOptions.currentPlatform,
class App extends ConsumerStatefulWidget { flavor: 'dev',
const App({super.key});
@override
ConsumerState<App> createState() => _AppState();
}
class _AppState extends ConsumerState<App> with WidgetsBindingObserver {
bool _fcmRegistered = false;
// Session the chat WS was on at the moment we backgrounded. Restored on
// resume so a backgrounded mitra reconnects to the same chat once they
// foreground the app. Mirrors the customer-app fix (main.dart on the
// client side) — backend's sendMessage checks recipient WS readyState
// before falling back to FCM, so leaving the WS open while paused makes
// FCM never fire and the mitra misses customer messages in background.
String? _pausedChatSessionId;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused || state == AppLifecycleState.detached) {
ref.read(onlineStatusProvider.notifier).onAppPaused();
// Close the chat WS so backend `sendMessage` falls back to FCM when
// the customer sends a message. Stash the active session_id so we
// can rejoin it on resume.
final chatNotifier = ref.read(mitraChatProvider.notifier);
final sid = chatNotifier.connectedSessionId;
if (sid != null) {
_pausedChatSessionId = sid;
chatNotifier.disconnect();
}
} else if (state == AppLifecycleState.resumed) {
ref.read(onlineStatusProvider.notifier).onAppResumed();
// Reconnect to the chat we backgrounded out of, if any.
final saved = _pausedChatSessionId;
_pausedChatSessionId = null;
if (saved != null) {
// ignore: discarded_futures
ref.read(mitraChatProvider.notifier).connect(saved);
}
}
}
void _registerFcmToken() {
if (_fcmRegistered) return;
_fcmRegistered = true;
Future(() async {
try {
final token = await FirebaseMessaging.instance.getToken();
if (token != null) {
await ref.read(apiClientProvider).post('/api/shared/device-token', data: {'token': token});
}
} catch (_) {
_fcmRegistered = false;
}
});
}
@override
Widget build(BuildContext context) {
// Listen for auth changes to load status and register FCM
ref.listen(mitraAuthProvider, (prev, next) {
final data = next.valueOrNull;
if (data is MitraAuthAuthenticatedData) {
ref.read(onlineStatusProvider.notifier).load();
_registerFcmToken();
}
});
final router = ref.watch(routerProvider);
NotificationService.initialize(router);
NotificationService.onChatRequestTapped = (sessionId) {
ref.read(chatRequestProvider.notifier).setIncomingFromNotification(sessionId);
};
return ChatRequestOverlay(
child: MaterialApp.router(
title: 'Halo Bestie Mitra',
theme: haloThemeData(),
routerConfig: router,
),
); );
}
}

View File

@@ -0,0 +1,9 @@
import 'bootstrap.dart';
import 'firebase/firebase_options_dev.dart';
/// DEV flavor entrypoint.
/// Run: flutter run --flavor dev -t lib/main_dev.dart --dart-define-from-file=env/dev.json
Future<void> main() => bootstrap(
firebaseOptions: DefaultFirebaseOptions.currentPlatform,
flavor: 'dev',
);

View File

@@ -0,0 +1,9 @@
import 'bootstrap.dart';
import 'firebase/firebase_options_prod.dart';
/// PROD flavor entrypoint.
/// Run: flutter run --flavor prod -t lib/main_prod.dart --dart-define-from-file=env/prod.json
Future<void> main() => bootstrap(
firebaseOptions: DefaultFirebaseOptions.currentPlatform,
flavor: 'prod',
);

View File

@@ -0,0 +1,9 @@
import 'bootstrap.dart';
import 'firebase/firebase_options_staging.dart';
/// STAGING flavor entrypoint.
/// Run: flutter run --flavor staging -t lib/main_staging.dart --dart-define-from-file=env/staging.json
Future<void> main() => bootstrap(
firebaseOptions: DefaultFirebaseOptions.currentPlatform,
flavor: 'staging',
);

View File

@@ -0,0 +1,191 @@
# Analytics Events Reference — client_app (GA4 / Firebase Analytics)
> Companion to `requirement/analytics-funnel-plan.md` (the design). This is the
> **single source of truth for what is actually instrumented** in client_app as
> of 2026-06-02. Any new event must be added to this table *before* it is coded
> (governance, plan §9).
>
> Scope: **client_app only** · `user_id` = customer UUID · **no PII** · client
> events live now; server (Measurement Protocol) events are deferred.
---
## 1. Identity & user properties
| Key | Set where | Values | Notes |
|---|---|---|---|
| `user_id` | auth listener (main.dart) on resolve/upgrade | customer UUID | opaque; same row across anon→verified. Never phone/name. |
| `user_type` (user property) | same | `anonymous` \| `verified` | |
| `is_returning` (user property) | available via `setIsReturning` | `true` \| `false` | wire when "has ≥1 prior session" signal is read |
---
## 2. Event dictionary (custom events)
Type: **C** = fired client-side now · **S** = server-side (Measurement Protocol), **deferred** · **auto** = Firebase auto-collected.
| Event | Type | Params | Trigger / location |
|---|---|---|---|
| `app_open` / `first_open` / `session_start` | auto | — | Firebase default |
| `screen_view` | auto (C) | `screen_name` | GoRouter observer — **page routes only** (see §4) |
| `curhat_start` | C | `funnel=activation`, `entry_point=home_primary` | Home "Aku Mau Curhat" CTA |
| `curhat_repeat_start` | C | `funnel=repeat` | Home "Aku Mau Curhat" (returning) / returning path |
| `bestie_choice_view` | C | — | `bestie_choice_sheet` shown (returning user with history) |
| `bestie_choice_select` | C | `choice=known_bestie\|new_bestie` | bestie-choice sheet card tap |
| `bestie_reselect` | C | `funnel=repeat`, `mitra_ref` (hashed) | `/bestie/history` row tap (targeted) |
| `verif_choice_view` | C | — | `verif_choice_sheet` shown (post anon-login) |
| `verif_choice_select` | C | `choice=verified\|anonymous` | verif-choice sheet decision (not on dismiss) |
| `auth_start` | C | `method=phone` | register screen "kirim kode" |
| `auth_otp_submit` | C | — | OTP screen submit |
| `auth_complete` | C | `user_type` | OTP verified resolve (verified) · display-name anon resolve (anonymous) |
| `onboarding_usp_view` | C | `verified` | USP screen initState |
| `payment_view` | C | `funnel`, `is_repeat` | `/payment/entry` initState |
| `payment_method_select` | C | `method` | payment-**channel** selection on `/payment/method` (once per change) — note: the chat/call mode picker is `curhat_mode_pick` on `/payment/method-pick` |
| `payment_started` ⭐ | C | `payment_request_id`, `amount`, `currency=IDR`, `method`, `funnel`, `is_repeat`, `product_type`, `duration_minutes` | `payment_method_screen._onPay`, **after** POST returns id |
| `pairing_matched` | C | `funnel` | `/chat/found` initState |
| `pairing_no_bestie` | C | `funnel` | `/chat/no-bestie` initState |
| `extension_offer_view` | C | `session_id` | `pricing_bottom_sheet` shown for extension (chat) |
| `chat_extension_requested` | C | `session_id` | user confirms extension (`PricingBottomSheet._onConfirm`) |
| `payment_confirmed` ⭐ | **S — deferred** | mirrors `payment_started` + `session_id`, `engagement_time_msec` | webhook → `payment_request.confirmed` |
| `payment_failed` | **S — deferred** | `payment_request_id`, `reason` | expiry/failure |
| `chat_session_start` ⭐ | **S — deferred** | `session_id`, `funnel`, `is_repeat`, `duration_minutes` | session.service start |
| `chat_session_end` | **S — deferred** | `session_id`, `end_reason`, `messages_count` | session end / timer |
`funnel`/`is_repeat` are derived from `paymentDraftNotifierProvider.targetedMitraId != null` (targeted mitra ⇒ repeat funnel).
### ⭐ Stitching keys carried on payment
Sent on `POST /api/client/payment-requests` body as `analytics:{ app_instance_id, ga_session_id }` (backend currently ignores). They let the deferred server `payment_confirmed` join the client funnel: `app_instance_id` (device/session), `user_id` (user), `payment_request_id` (exact attempt). Full rationale: plan §3.
---
## 3. Screen views tracked (page routes)
Auto `screen_view` fires for every GoRoute, mapped to a stable `screen_name` (path params stripped):
`splash · auth_display_name · auth_register · auth_otp · auth_set_name · auth_force_register · onboarding_usp_verified · onboarding_usp_anon · onboarding_notif_gate · home · profile · payment_entry · payment_discount_paywall · curhat_mode_pick · payment_duration_pick · payment_method · payment_waiting · payment_expired · chat_searching · chat_found · chat_no_bestie · chat_waiting_targeted · chat_session · chat_thank_you · chat_tab_aktif · chat_tab_pembayaran · chat_tab_selesai · chat_transcript · bestie_history`
---
## 4. Bottom sheets & modals
Sheets/dialogs (`showModalBottomSheet` / `showDialog`) push routes with a **null `RouteSettings.name`**, so the `FirebaseAnalyticsObserver` skips them — they get **no auto `screen_view`**. Funnel-relevant sheets are instead instrumented with explicit `*_view` / `*_select` events (logged from the sheet's show/onTap). Each tracked sheet fires a `view` when shown and a `select` when the user acts; the **gap between them = abandonment**.
| Sheet / dialog | Funnel relevance | Tracking |
|---|---|---|
| `verif_choice_sheet` (verify vs anonymous) | **high** | ✅ `verif_choice_view` + `verif_choice_select{choice}` |
| `bestie_choice_sheet` (new vs known bestie fork) | **high** | ✅ `bestie_choice_view` + `bestie_choice_select{choice}` |
| `pricing_bottom_sheet` (extension upsell in chat) | **medium** (monetization) | ✅ `extension_offer_view` + `chat_extension_requested` |
| `topic_selection_bottom_sheet` (pre-chat topic pick) | — | ⬜ **dead code**`.show()` never called; track only once wired into a flow |
| `tanya_admin_sheet` (support) | low | ⬜ not tracked (negligible funnel value) |
| `bestie_unavailable_dialog` | low | ⬜ not tracked |
| `closing_message_sheet` (goodbye) | low | ⬜ not tracked |
**Why not the rest:** the `verif_choice` and `bestie_choice` *outcomes* are also inferable from downstream events (`auth_start` vs anon `payment_view`; `bestie_history` view vs direct `payment_view`) — the explicit events add the **abandonment** signal you can't otherwise see, plus one-step branch clarity. The extension pair is pure net-new (no other event covers extension take-rate). The low-tier sheets are support/edge surfaces and intentionally left untracked to avoid noise.
---
## 5. Visual flows
Two views of the same instrumentation:
- **5.1 Funnel event flow** — the abstract conversion funnel (what GA4 reports on).
- **5.2 Screen navigation map** — the real route/screen/sheet flow with each event pinned to where it fires (what you'll see live in DebugView).
### 5.1 Funnel event flow
```mermaid
flowchart TD
classDef evt fill:#FFE3F0,stroke:#FF699F,color:#7A1F47;
classDef srv fill:#E3ECFF,stroke:#3B6FE0,color:#1B2E5A,stroke-dasharray:4 3;
classDef sheet fill:#FFF6D9,stroke:#D9A400,color:#5A4500;
AO([app_open / session_start]):::evt --> HOME[screen_view: home]
%% ---- Activation funnel ----
HOME --> CS{{bestie_choice_sheet — not viewed}}:::sheet
CS -->|new bestie| CSTART([curhat_start<br/>funnel=activation]):::evt
CSTART --> AUTH[screen_view: auth_register]
AUTH --> ASTART([auth_start method=phone]):::evt
ASTART --> OTP[screen_view: auth_otp]
OTP --> AOTP([auth_otp_submit]):::evt
AOTP --> ACOMP([auth_complete user_type]):::evt
ACOMP --> USP([onboarding_usp_view]):::evt
USP --> PV([payment_view funnel,is_repeat]):::evt
PV --> PMS([payment_method_select method]):::evt
PMS --> PSTART([payment_started ⭐<br/>+ app_instance_id, ga_session_id sent on POST]):::evt
PSTART --> PCONF([payment_confirmed ⭐<br/>SERVER — deferred]):::srv
PCONF --> PM{pairing}
PM -->|matched| PMATCH([pairing_matched]):::evt
PM -->|none| PNB([pairing_no_bestie]):::evt
PMATCH --> CSS([chat_session_start ⭐<br/>SERVER — deferred]):::srv
CSS --> CSE([chat_session_end<br/>SERVER — deferred]):::srv
%% ---- Repeat funnel ----
HOME --> RSTART([curhat_repeat_start<br/>funnel=repeat]):::evt
RSTART --> BHIST[screen_view: bestie_history]
BHIST --> BRESEL([bestie_reselect funnel=repeat]):::evt
BRESEL --> PV2([payment_view is_repeat=true]):::evt
PV2 --> PMS
```
Legend — pink = client event (live) · blue dashed = server event (deferred) · yellow = bottom sheet (not auto-tracked).
### 5.2 Screen navigation map (routes + sheets + events)
Real GoRouter routes (blue `screen_view` nodes), bottom sheets (yellow, **no** `screen_view`), and the exact event each transition fires (pink = live, dashed = deferred server). Both home CTAs read **"Aku Mau Curhat"**; the path taken depends on auth state, not the label.
```mermaid
flowchart TD
classDef screen fill:#E3ECFF,stroke:#3B6FE0,color:#1B2E5A;
classDef evt fill:#FFE3F0,stroke:#FF699F,color:#7A1F47;
classDef srv fill:#EFE3FF,stroke:#7B3BE0,color:#2E1B5A,stroke-dasharray:4 3;
classDef sheet fill:#FFF6D9,stroke:#D9A400,color:#5A4500;
SPL[/splash/]:::screen --> HOME[/home/]:::screen
%% ===== FRESH USER (activation) =====
HOME -->|tap CTA · curhat_start| DN[/auth/display-name/]:::screen
DN -.loginAnonymous.-> VCS{{verif_choice_sheet<br/>verif_choice_view}}:::sheet
VCS -->|verify · verif_choice_select| REG[/auth/register/]:::screen
VCS -->|lanjut tanpa verif · verif_choice_select| PE
REG -->|auth_start| OTP[/auth/otp/]:::screen
OTP -->|auth_otp_submit → auth_complete| UVU[/onboarding/verif/usp/]:::screen
UVU -->|onboarding_usp_view| PE
%% ===== RETURNING USER (repeat) =====
HOME -->|tap CTA · curhat_repeat_start| BCS{{bestie_choice_sheet<br/>bestie_choice_view}}:::sheet
BCS -->|new bestie · bestie_choice_select| PE
BCS -->|known bestie · bestie_choice_select| BHL[/bestie/history/]:::screen
BHL -->|tap row · bestie_reselect| PE
%% ===== SHARED PAYMENT SHELL =====
%% NOTE: /payment/method-pick is the chat-vs-call MODE picker (curhat_mode_pick),
%% NOT the channel picker. The channel picker is /payment/method (payment_method),
%% where payment_method_select fires.
PE[/payment/entry/<br/>payment_view/]:::screen --> MODE[/payment/method-pick/<br/>curhat_mode_pick/]:::screen
MODE -->|chat / call| DUR[/payment/duration-pick/<br/>payment_duration_pick/]:::screen
DUR --> PMETH[/payment/method/<br/>payment_method + payment_method_select/]:::screen
PMETH -->|tap bayar · payment_started ⭐<br/>+ app_instance_id & ga_session_id on POST| WP[/payment/waiting/:id/]:::screen
WP -.->|payment_confirmed ⭐ SERVER deferred| PCONF([backend webhook]):::srv
%% ===== PAIRING + CHAT =====
PCONF --> SRCH[/chat/searching/]:::screen
SRCH -->|matched · pairing_matched| FOUND[/chat/found/]:::screen
SRCH -->|none · pairing_no_bestie| NOB[/chat/no-bestie/]:::screen
FOUND --> SESS[/chat/session/:id/]:::screen
SESS -.->|chat_session_start / _end ⭐ SERVER deferred| SSRV([session.service]):::srv
SESS -->|tap perpanjang| EXT{{pricing_bottom_sheet<br/>extension_offer_view}}:::sheet
EXT -->|confirm · chat_extension_requested| SESS
```
Legend — blue = page route (auto `screen_view`) · pink label = client event fired on that transition · yellow = bottom sheet (no `screen_view`) · purple dashed = deferred server event.
> The three funnel-relevant sheets — **verify-vs-anonymous** (`verif_choice_sheet`), **new-vs-known-bestie** (`bestie_choice_sheet`), and the **extension upsell** (`pricing_bottom_sheet`) — each fire a `*_view` on show and a `*_select` / `chat_extension_requested` on action, so both the branch taken and sheet abandonment are measurable. See §4 for which sheets are intentionally left untracked.
---
## 6. GA4 setup checklist (console)
- Register custom dimensions: `funnel`, `is_repeat`, `method`, `user_type`, `payment_request_id`, `product_type`, `end_reason`.
- Mark key events / conversions: `payment_confirmed`, `chat_session_start` (once server phase lands; until then `payment_started` is the furthest reliable client conversion).
- Build two Funnel Explorations (activation / repeat) filtered by `funnel` / `is_repeat`.
- Validate end-to-end in **DebugView** with a debug build before release.

View File

@@ -0,0 +1,235 @@
# Funnel Analytics Plan — Firebase Analytics (GA4)
> Status: PLAN / draft for review. Scope decisions (2026-06-02):
> **client_app only** · **hybrid client+server events** · **user_id = customer UUID, no PII** · **full-lifecycle taxonomy** (activation + repeat/retention).
---
## 1. Objectives
1. Measure the **activation funnel** (acquisition → first paid chat) and the **repeat/retention funnel** (returning user → curhat lagi → paid chat) in one consistent event taxonomy.
2. Attribute drop-off to specific screens/steps so product can act on it.
3. Keep authoritative money/session events **server-side** so they are never lost when the app is backgrounded or killed mid-payment.
4. **No PII** ever leaves the device into GA4 — no phone number, display name, or chat content. Identity is an opaque customer UUID only.
---
## 2. The two funnels (full lifecycle map)
Screen/route references are from `client_app/lib/router.dart`.
### Funnel A — Activation (first paid chat)
| # | Step (GA4 funnel step) | Event | Where |
|---|---|---|---|
| 1 | App open | `app_open` (auto) | Firebase auto |
| 2 | Home viewed | `screen_view{home}` | `/home` |
| 3 | Start curhat tapped | `curhat_start` | Home CTA "Aku Mau Curhat" |
| 4 | Auth started | `auth_start` | `/auth/display-name` / register |
| 5 | OTP submitted | `auth_otp_submit` | `/auth/otp` |
| 6 | Identified (verified or anon) | `auth_complete` | post-OTP / loginAnonymous |
| 7 | USP/onboarding seen | `onboarding_usp_view` | `/onboarding/*/usp` |
| 8 | Payment entry | `payment_view` | `/payment/entry` |
| 9 | Payment channel chosen | `payment_method_select` | `/payment/method` (channel picker; the chat/call mode picker is `curhat_mode_pick` on `/payment/method-pick`) |
| 10 | Payment created (invoice) | `payment_started` ⭐ | POST `/api/client/payment-requests` |
| 11 | **Payment confirmed** | `payment_confirmed`**SERVER** | webhook → `payment_request.confirmed` |
| 12 | Paired with mitra | `pairing_matched` | `/chat/found` |
| 13 | Chat started | `chat_session_start`**SERVER** | session.service start |
| 14 | Chat ended | `chat_session_end` **SERVER** | session end / timer |
### Funnel B — Repeat / retention
| # | Step | Event | Where |
|---|---|---|---|
| 1 | Returning home | `screen_view{home}` | `/home` (has history) |
| 2 | "Curhat lagi" | `curhat_repeat_start` | BestieChoiceSheet |
| 3 | Picked known bestie | `bestie_reselect` | `/bestie/history` |
| 4 | Payment created | `payment_started` ⭐ (`is_repeat=true`) | targeted payment |
| 5 | Payment confirmed | `payment_confirmed`**SERVER** | webhook |
| 6 | Targeted pairing | `pairing_targeted_*` | `/chat/waiting-targeted` |
| 7 | Chat started | `chat_session_start`**SERVER** | session start |
⭐ = the events that must join cleanly across client→server (see §3).
Funnel A and B share the same `payment_started` / `payment_confirmed` / `chat_session_start` events — they are distinguished by the **`funnel` / `is_repeat` event params**, not by separate event names. This keeps GA4 reports simple and lets one funnel exploration filter by param.
---
## 3. ⭐ The hybrid join problem — "which payment event relates to which funnel?"
This is the central design question. Answer: **three identifiers travel with every payment, so a server-fired `payment_confirmed` lands on the exact same user, session, and attempt as the client-fired `payment_started`.**
### 3.1 The three join keys
| Key | Joins at level | Who sets it | How it flows |
|---|---|---|---|
| `app_instance_id` | **device/app-instance** (required by GA4 MP for app streams) | Firebase SDK on device | client reads `FirebaseAnalytics.instance.appInstanceId`, sends it on payment-create, backend stores in `product_metadata`, replays it in the MP call |
| `user_id` | **user** (cross-device, cross-session) | our app | customer UUID set on both client `setUserId()` and server MP payload |
| `payment_request_id` | **attempt** (this specific purchase) | backend | returned by POST `/payment-requests`; client puts it on `payment_started`, backend puts the same value on `payment_confirmed` |
> **Why all three:** GA4's Measurement Protocol for app streams *requires* `app_instance_id` to attribute a server event to a user's stream — `user_id` alone will record the event but standard funnel/realtime reports won't stitch it to the device's session. `user_id` gives cross-device continuity (anon→verified). `payment_request_id` is the precise attempt-level join used in Explorations/BigQuery to tie one `payment_started` to its `payment_confirmed` (compute exact payment success rate & latency).
### 3.2 The flow, concretely
```
CLIENT payment_method_screen.dart
appInstanceId = await FirebaseAnalytics.instance.appInstanceId
POST /api/client/payment-requests
body: { ...draft, analytics: { app_instance_id, ga_session_id } }
← { id: <payment_request_id>, invoice_url }
analytics.log('payment_started', {
payment_request_id, amount, currency:'IDR', method,
funnel:'activation'|'repeat', is_repeat, product_type })
BACKEND payment.service.createPaymentRequest()
store analytics.app_instance_id + ga_session_id into product_metadata
BACKEND payment.service.confirmPayment() (fired from Xendit webhook)
emits 'payment_request.confirmed'
→ analytics subscriber → GA4 Measurement Protocol POST:
app_instance_id = product_metadata.app_instance_id ← stitches device/session
user_id = customer_id ← stitches user
events: [{ name:'payment_confirmed', params:{
payment_request_id, amount, currency, method,
funnel, is_repeat, session_id: ga_session_id,
engagement_time_msec: 1 }}]
```
`session_id` + `engagement_time_msec` in the MP params are what make GA4 attribute the server event to the **same session** as the client funnel (needed only for *session-scoped* funnel explorations; user-scoped funnels already work via `app_instance_id`+`user_id`). We capture `ga_session_id` client-side (`getSessionId()`) at payment-create and replay it.
### 3.3 Net result
- **User-scoped funnel** (default): works via `app_instance_id` + `user_id`.
- **Session-scoped funnel**: works because we replay `ga_session_id`.
- **Exact attempt analysis** (success rate, time-to-pay): join `payment_started``payment_confirmed` on `payment_request_id` in BigQuery/Explore.
The same pattern covers `chat_session_start` / `chat_session_end` (server-authoritative) — keyed by `session_id` + `app_instance_id` + `user_id`.
---
## 4. Identity & user properties
- `setUserId(customerId)` — the customer UUID (same row across anon→verified via `anonymous_customer_id`). Set on app start once auth resolves, and re-set after identity upgrade so the verified session continues the same `user_id`.
- **User properties** (low-cardinality, no PII):
- `user_type` = `anonymous` | `verified`
- `is_returning` = whether the customer has ≥1 prior confirmed session
- `acquisition_channel` (if/when known)
- **Never** set: phone, display name, email, chat text, mitra identity.
- Set GA4 data retention + IP anonymization per the mental-health sensitivity; document in privacy section §9.
---
## 5. Master event taxonomy
Naming: `snake_case`, object_action where natural, ≤40 chars. Reserved Firebase events (`app_open`, `screen_view`, `first_open`, `session_start`) are auto-collected — do not redefine.
| Event | Client/Server | Key params | Notes |
|---|---|---|---|
| `screen_view` | client (auto via observer) | `screen_name` | go_router observer, §6 |
| `curhat_start` | client | `funnel='activation'`, `entry_point` | Home primary CTA |
| `curhat_repeat_start` | client | `funnel='repeat'` | returning CTA |
| `bestie_reselect` | client | `mitra_ref` (opaque) | `/bestie/history` |
| `auth_start` | client | `method` (phone/google/apple) | |
| `auth_otp_submit` | client | — | |
| `auth_complete` | client | `user_type` | |
| `onboarding_usp_view` | client | `verified` | |
| `payment_view` | client | `funnel`, `is_repeat` | `/payment/entry` |
| `payment_method_select` | client | `method` | |
| `payment_started` ⭐ | client | `payment_request_id`, `amount`, `currency`, `method`, `funnel`, `is_repeat`, `product_type`, `duration_minutes` | fired right after POST returns id |
| `payment_confirmed` ⭐ | **server (MP)** | same as above + `session_id`, `engagement_time_msec` | from webhook |
| `payment_failed` | **server (MP)** | `payment_request_id`, `reason` | expiry/failure |
| `pairing_matched` | client | `funnel` | `/chat/found` |
| `pairing_no_bestie` | client | `funnel` | `/chat/no-bestie` |
| `chat_session_start` ⭐ | **server (MP)** | `session_id`, `funnel`, `is_repeat`, `duration_minutes` | authoritative |
| `chat_session_end` | **server (MP)** | `session_id`, `end_reason`, `messages_count` | authoritative |
| `chat_extension_requested` | client | `session_id` | optional |
| `app_open` / `session_start` / `first_open` | auto | — | Firebase default |
> Keep custom params to those used in funnels/segments. Register the high-value ones as **custom dimensions** in GA4 (`funnel`, `is_repeat`, `method`, `user_type`, `payment_request_id`) so they're queryable.
---
## 6. Client implementation (Flutter)
1. **Add deps** to `client_app/pubspec.yaml`: `firebase_analytics`. Re-run `flutterfire configure` if needed (firebase_core already present; messaging configured).
2. **Init** in bootstrap after `Firebase.initializeApp()`: enable collection, set default user properties.
3. **Analytics service wrapper**`core/analytics/analytics.dart`:
- Thin façade over `FirebaseAnalytics.instance` with typed methods (`logCurhatStart`, `logPaymentStarted`, …) so event names/params are centralized and not stringly-typed at call sites.
- Exposes `appInstanceId()` and `sessionId()` helpers for the payment-create call.
- Riverpod provider `analyticsProvider` for injection.
4. **Auto screen_view** — add `FirebaseAnalyticsObserver` to `GoRouter(observers: [...])`. Map routes → clean `screen_name`s (avoid leaking path params like `:sessionId`).
5. **user_id wiring** — in the auth notifier listener, call `setUserId` + update `user_type`/`is_returning` user properties whenever auth state resolves/upgrades.
6. **Instrument the funnel call sites** per §5 (CTAs, OTP submit, payment screens, pairing screens). Fire `payment_started` only after the POST returns a `payment_request_id`.
> Pitfall guard (per client_app/CLAUDE.md): analytics calls inside widget teardown go in `deactivate()`, not `dispose()` — but prefer firing on the user action, not on screen disposal.
---
## 7. Backend implementation (Measurement Protocol)
1. **Config/env**: `GA4_API_SECRET`, `GA4_FIREBASE_APP_ID`, `GA4_MP_ENABLED` (default off, opt-in like Xendit/Fazpass flags). Endpoint: `https://www.google-analytics.com/mp/collect`.
2. **Capture identifiers**: extend POST `/api/client/payment-requests` to accept `analytics:{app_instance_id, ga_session_id}` and persist into `product_metadata` (already JSONB, already "for analytics").
3. **Analytics emitter service**`services/analytics-mp.service.js`:
- `sendEvent({ appInstanceId, userId, name, params })` → builds MP payload, POSTs, logs failures non-fatally (analytics must never break payment).
- Always include `engagement_time_msec` and `session_id` for app-stream session attribution.
4. **Subscribe to existing internal events** (no new webhook plumbing needed):
- `payment_request.confirmed``payment_confirmed`
- payment expiry/failure → `payment_failed`
- session start/end (session.service) → `chat_session_start` / `chat_session_end`
5. **Validation**: use GA4 MP **debug endpoint** (`/debug/mp/collect`) in dev to assert payloads before enabling.
> Reliability: MP sends are fire-and-forget with a short timeout + retry-once; wrap in try/catch so a GA outage never affects the money path. Consider a lightweight outbox if we later need delivery guarantees.
---
## 8. GA4 configuration (console)
1. Create/confirm the **Firebase Analytics → GA4 property** for client_app (Android + iOS app streams).
2. Register **custom dimensions**: `funnel`, `is_repeat`, `method`, `user_type`, `payment_request_id`, `product_type`, `end_reason`.
3. Build **two Funnel Explorations**:
- *Activation*: steps = `curhat_start → auth_complete → payment_started → payment_confirmed → pairing_matched → chat_session_start`, filter `funnel=activation`.
- *Repeat*: steps = `curhat_repeat_start → payment_started → payment_confirmed → chat_session_start`, filter `is_repeat=true`.
4. Mark **conversions/key events**: `payment_confirmed`, `chat_session_start`.
5. (Optional, recommended) Enable **BigQuery export** for attempt-level joins (`payment_started``payment_confirmed` on `payment_request_id`) that GA's UI can't express precisely.
6. Use **DebugView** with a debug build to validate the full funnel end-to-end before release.
---
## 9. Privacy & governance
- **No PII** in events or user properties — enforce via the typed wrapper (no free-form string params at call sites).
- `user_id` is an opaque UUID; document the mapping policy and retention.
- Respect OS-level analytics/ads consent; gate collection behind app config so it can be disabled.
- Add a one-page **event dictionary** (this §5 table) to `requirement/` and keep it the single source of truth; any new event gets added here first (governance).
- Set GA4 data retention to the minimum that supports the funnels; enable IP anonymization.
---
## 10. Implementation phases / checklist
**Phase 1 — Client foundation**
- [ ] Add `firebase_analytics`; init + collection toggle
- [ ] `AnalyticsService` typed wrapper + Riverpod provider
- [ ] `FirebaseAnalyticsObserver` on GoRouter + screen_name map
- [ ] `setUserId` + user properties in auth listener
**Phase 2 — Client funnel events**
- [ ] Activation events (curhat_start … pairing_matched)
- [ ] Repeat events (curhat_repeat_start, bestie_reselect)
- [ ] `payment_started` after POST returns id; capture `app_instance_id` + `ga_session_id`, send on payment-create
**Phase 3 — Backend server events (hybrid)**
- [ ] Persist `analytics` identifiers into `product_metadata`
- [ ] `analytics-mp.service.js` + env flags
- [ ] Subscribe to `payment_request.confirmed``payment_confirmed`
- [ ] Subscribe to session start/end → `chat_session_*`
- [ ] Validate via MP debug endpoint
**Phase 4 — GA4 config + validation**
- [ ] Custom dimensions, conversions
- [ ] Two funnel explorations
- [ ] DebugView end-to-end pass on a real device
- [ ] (Optional) BigQuery export
---
## 11. Open questions for product
1. Confirm GA4 property already exists for client_app (or do we create fresh)? Re separate prod/dev Firebase projects — see existing `firebase_env_strategy` note.
2. Do we want BigQuery export from day one (enables exact attempt-level payment analytics)?
3. Retention window + any consent-banner requirement for the mental-health context?