Compare commits
6 Commits
12cf9f80e9
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 816e037a9a | |||
| 91bdbd5289 | |||
| be20eee16b | |||
| d04f6a8a69 | |||
| 48a1f8eb65 | |||
| 22743c81e1 |
65
.github/workflows/README.md
vendored
Normal file
65
.github/workflows/README.md
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
# CI Workflows
|
||||
|
||||
## `build.yml` — parameterized build
|
||||
|
||||
Manually-triggered, **build-only** pipeline (no push, no deploy). Produces downloadable artifacts.
|
||||
|
||||
### How to run
|
||||
1. GitHub → **Actions** tab → **Build** (left sidebar) → **Run workflow**.
|
||||
2. Pick:
|
||||
- **environment** — `staging` or `prod`
|
||||
- **target** — `all`, `customer`, `mitra`, `backend`, or `control_center`
|
||||
- **platform** — `android`, `ios`, or `both` (only affects the customer/mitra apps; ignored for backend/control_center)
|
||||
3. **Run workflow**. Open the run; artifacts appear at the bottom of the summary page when it finishes.
|
||||
|
||||
(You can also trigger it from the CLI: `gh workflow run build.yml -f environment=staging -f target=all -f platform=android`.)
|
||||
|
||||
### Runner split (Linux vs macOS)
|
||||
iOS can only be built on macOS, so the workflow routes jobs accordingly:
|
||||
|
||||
| Job | Runner |
|
||||
|---|---|
|
||||
| `customer-ios`, `mitra-ios` | `macos-latest` |
|
||||
| `customer-android`, `mitra-android`, `backend`, `control_center` | `ubuntu-latest` |
|
||||
|
||||
> ⚠️ **macOS runner minutes bill at ~10× Linux.** That's why `platform` defaults to `android`. Choose `ios`/`both` deliberately.
|
||||
|
||||
### What each target produces
|
||||
|
||||
| Target | Platform | Tool | Artifact | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `customer` | android | Flutter | `customer-<env>-android-apk` → `app-<env>-release.apk` | Debug-signed (no keystore in repo). Fine for internal/Firebase App Distribution. |
|
||||
| `customer` | ios | Flutter | `customer-<env>-ios-app` (`Runner.app`) | **Unsigned** (`--no-codesign`). See iOS prerequisites below. |
|
||||
| `mitra` | android | Flutter | `mitra-<env>-android-apk` | Same as customer-android. |
|
||||
| `mitra` | ios | Flutter | `mitra-<env>-ios-app` | Same caveats as customer-ios. |
|
||||
| `control_center` | — | Vite | `control-center-<env>-dist` (the `dist/` folder) | `VITE_API_BASE_URL` baked in at build time — see below. |
|
||||
| `backend` | — | Docker | `backend-<env>-image` (`*.tar.gz`) | Env-**agnostic** image (config is runtime env vars). Load with `docker load`. |
|
||||
|
||||
`all` runs every selected job in parallel; each is independent, so one failing doesn't block the others.
|
||||
|
||||
### ⚠️ iOS prerequisites (not satisfied yet)
|
||||
The iOS jobs are wired but will **fail until two things are done**:
|
||||
1. **Per-flavor Xcode schemes** (`staging`, `prod`) must exist in `ios/Runner.xcodeproj`. Today there's only the default `Runner` scheme, so `flutter build ios --flavor staging` errors with *"The Xcode project does not define custom schemes"*. This mirrors the Android flavor setup but on the iOS side (schemes + build configurations + per-config bundle IDs).
|
||||
2. **Code signing** for a distributable `.ipa`: Apple Developer certificate + provisioning profiles stored as GitHub secrets. The current jobs use `--no-codesign`, which only validates that the app **compiles** and produces an unsigned `Runner.app` (not installable on devices).
|
||||
|
||||
Until then, `platform=ios`/`both` is useful only once the schemes land. `platform=android` works today.
|
||||
|
||||
### Environment specifics
|
||||
- **Apps (customer/mitra):** `environment` selects the Flutter flavor + entrypoint + `env/<env>.json` (`--flavor`, `-t lib/main_<env>.dart`, `--dart-define-from-file`). Reminder: `env/staging.json` still has a **placeholder API URL** until the staging backend is deployed.
|
||||
- **control_center:** `VITE_API_BASE_URL` is compiled in. Defaults: prod → `https://internal.halobestie.com`, staging → `https://staging-internal.halobestie.com`. Override without editing the workflow by setting repo **Variables** (Settings → Secrets and variables → Actions → Variables): `CC_API_BASE_URL_PROD`, `CC_API_BASE_URL_STAGING`.
|
||||
- **backend:** the image is identical across environments; env/secrets are supplied at `docker run` time (see [backend/DEPLOY.md](../../backend/DEPLOY.md)). The `<env>` in the artifact name is just a label.
|
||||
|
||||
### Deploying a build artifact (manual, since we don't push yet)
|
||||
- **Backend:** download `backend-<env>-image`, copy to the host, then:
|
||||
```bash
|
||||
gunzip -c halobestie-backend-<env>.tar.gz | docker load
|
||||
# then run per backend/DEPLOY.md
|
||||
```
|
||||
- **control_center:** download `control-center-<env>-dist`, serve the `dist/` behind Nginx (internal-only).
|
||||
- **Apps:** download the APK and install / upload to Firebase App Distribution.
|
||||
|
||||
### Pinned versions
|
||||
Flutter `3.41.9`, JDK `17`, Node `20` (see `env:` block in [build.yml](build.yml)). Bump them there when the team upgrades.
|
||||
|
||||
### Not included yet (by design)
|
||||
Pushing images to a registry and deploying are intentionally out of scope for this first iteration. When you're ready, the natural next step is a `release.yml` that pushes to GHCR and (optionally, via a self-hosted runner or SSH) deploys to the VPS.
|
||||
258
.github/workflows/build.yml
vendored
Normal file
258
.github/workflows/build.yml
vendored
Normal file
@@ -0,0 +1,258 @@
|
||||
name: Build
|
||||
|
||||
# Manually triggered, parameterized build pipeline (build-only — no push/deploy).
|
||||
#
|
||||
# Trigger: GitHub → Actions tab → "Build" → "Run workflow" → pick:
|
||||
# environment: staging | prod (which flavor/config to build)
|
||||
# target: all | customer | mitra | backend | control_center
|
||||
# platform: android | ios | both (only affects the customer/mitra apps)
|
||||
#
|
||||
# Runner split: iOS app jobs run on macOS runners (iOS can't build on Linux);
|
||||
# everything else (Android, backend, control_center) runs on Linux.
|
||||
#
|
||||
# Outputs land as downloadable artifacts on the run summary page.
|
||||
# See .github/workflows/README.md for details.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
environment:
|
||||
description: "Build environment"
|
||||
type: choice
|
||||
required: true
|
||||
default: staging
|
||||
options: [staging, prod]
|
||||
target:
|
||||
description: "What to build"
|
||||
type: choice
|
||||
required: true
|
||||
default: all
|
||||
options: [all, customer, mitra, backend, control_center]
|
||||
platform:
|
||||
description: "Mobile platform (apps only; macOS runners cost ~10x)"
|
||||
type: choice
|
||||
required: true
|
||||
default: android
|
||||
options: [android, ios, both]
|
||||
|
||||
run-name: "Build ${{ inputs.target }} (${{ inputs.environment }}, ${{ inputs.platform }})"
|
||||
|
||||
# Cancel an in-flight run of the same target+env+platform when a new one starts.
|
||||
concurrency:
|
||||
group: build-${{ inputs.target }}-${{ inputs.environment }}-${{ inputs.platform }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
FLUTTER_VERSION: "3.41.9" # keep in sync with the team's local Flutter
|
||||
JAVA_VERSION: "17" # AGP 8 requires JDK 17
|
||||
NODE_VERSION: "20" # matches backend engines + control_center
|
||||
|
||||
jobs:
|
||||
# ---------------------------------------------------------------------------
|
||||
# Expand the `target` input into a JSON list the build jobs gate on.
|
||||
# `all` → every component; otherwise just the one chosen.
|
||||
# ---------------------------------------------------------------------------
|
||||
prepare:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
targets: ${{ steps.expand.outputs.targets }}
|
||||
steps:
|
||||
- id: expand
|
||||
run: |
|
||||
if [ "${{ inputs.target }}" = "all" ]; then
|
||||
echo 'targets=["customer","mitra","backend","control_center"]' >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo 'targets=["${{ inputs.target }}"]' >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- run: echo "Building → ${{ steps.expand.outputs.targets }} (env=${{ inputs.environment }}, platform=${{ inputs.platform }})"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# customer app — ANDROID (Linux runner). Debug-signed (no keystore in repo).
|
||||
# ---------------------------------------------------------------------------
|
||||
customer-android:
|
||||
needs: prepare
|
||||
if: contains(fromJSON(needs.prepare.outputs.targets), 'customer') && (inputs.platform == 'android' || inputs.platform == 'both')
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: client_app
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: ${{ env.JAVA_VERSION }}
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
channel: stable
|
||||
cache: true
|
||||
- run: flutter pub get
|
||||
- name: Build APK
|
||||
run: |
|
||||
flutter build apk \
|
||||
--flavor ${{ inputs.environment }} \
|
||||
-t lib/main_${{ inputs.environment }}.dart \
|
||||
--dart-define-from-file=env/${{ inputs.environment }}.json
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: customer-${{ inputs.environment }}-android-apk
|
||||
path: client_app/build/app/outputs/flutter-apk/app-${{ inputs.environment }}-release.apk
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# customer app — iOS (macOS runner). Unsigned build (--no-codesign).
|
||||
# PREREQ: per-flavor Xcode schemes must exist (staging/prod). Until then this
|
||||
# fails with "The Xcode project does not define custom schemes". Distributable
|
||||
# IPAs additionally need signing certs/profiles as secrets — not set up yet.
|
||||
# ---------------------------------------------------------------------------
|
||||
customer-ios:
|
||||
needs: prepare
|
||||
if: contains(fromJSON(needs.prepare.outputs.targets), 'customer') && (inputs.platform == 'ios' || inputs.platform == 'both')
|
||||
runs-on: macos-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: client_app
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
channel: stable
|
||||
cache: true
|
||||
- run: flutter pub get
|
||||
- name: Build iOS (unsigned)
|
||||
run: |
|
||||
flutter build ios --no-codesign \
|
||||
--flavor ${{ inputs.environment }} \
|
||||
-t lib/main_${{ inputs.environment }}.dart \
|
||||
--dart-define-from-file=env/${{ inputs.environment }}.json
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: customer-${{ inputs.environment }}-ios-app
|
||||
path: client_app/build/ios/iphoneos/Runner.app
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# mitra app — ANDROID (Linux runner).
|
||||
# ---------------------------------------------------------------------------
|
||||
mitra-android:
|
||||
needs: prepare
|
||||
if: contains(fromJSON(needs.prepare.outputs.targets), 'mitra') && (inputs.platform == 'android' || inputs.platform == 'both')
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: mitra_app
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: ${{ env.JAVA_VERSION }}
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
channel: stable
|
||||
cache: true
|
||||
- run: flutter pub get
|
||||
- name: Build APK
|
||||
run: |
|
||||
flutter build apk \
|
||||
--flavor ${{ inputs.environment }} \
|
||||
-t lib/main_${{ inputs.environment }}.dart \
|
||||
--dart-define-from-file=env/${{ inputs.environment }}.json
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mitra-${{ inputs.environment }}-android-apk
|
||||
path: mitra_app/build/app/outputs/flutter-apk/app-${{ inputs.environment }}-release.apk
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# mitra app — iOS (macOS runner). Same PREREQ caveats as customer-ios.
|
||||
# ---------------------------------------------------------------------------
|
||||
mitra-ios:
|
||||
needs: prepare
|
||||
if: contains(fromJSON(needs.prepare.outputs.targets), 'mitra') && (inputs.platform == 'ios' || inputs.platform == 'both')
|
||||
runs-on: macos-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: mitra_app
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
channel: stable
|
||||
cache: true
|
||||
- run: flutter pub get
|
||||
- name: Build iOS (unsigned)
|
||||
run: |
|
||||
flutter build ios --no-codesign \
|
||||
--flavor ${{ inputs.environment }} \
|
||||
-t lib/main_${{ inputs.environment }}.dart \
|
||||
--dart-define-from-file=env/${{ inputs.environment }}.json
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mitra-${{ inputs.environment }}-ios-app
|
||||
path: mitra_app/build/ios/iphoneos/Runner.app
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# control_center — Vite SPA static build (Linux). VITE_API_BASE_URL is baked
|
||||
# in at build time. Override defaults via repo Variables CC_API_BASE_URL_PROD
|
||||
# / CC_API_BASE_URL_STAGING (Settings → Secrets and variables → Actions).
|
||||
# ---------------------------------------------------------------------------
|
||||
control_center:
|
||||
needs: prepare
|
||||
if: contains(fromJSON(needs.prepare.outputs.targets), 'control_center')
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: control_center
|
||||
env:
|
||||
VITE_API_BASE_URL: >-
|
||||
${{ inputs.environment == 'prod'
|
||||
&& (vars.CC_API_BASE_URL_PROD || 'https://internal.halobestie.com')
|
||||
|| (vars.CC_API_BASE_URL_STAGING || 'https://staging-internal.halobestie.com') }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
cache-dependency-path: control_center/package-lock.json
|
||||
- run: npm ci
|
||||
- name: Build (VITE_API_BASE_URL=${{ env.VITE_API_BASE_URL }})
|
||||
run: npm run build
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: control-center-${{ inputs.environment }}-dist
|
||||
path: control_center/dist
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# backend — Docker image (Linux). Env-AGNOSTIC (config is runtime env vars),
|
||||
# built once and exported as a loadable tarball. Deploy without a registry:
|
||||
# gunzip -c halobestie-backend-*.tar.gz | docker load
|
||||
# ---------------------------------------------------------------------------
|
||||
backend:
|
||||
needs: prepare
|
||||
if: contains(fromJSON(needs.prepare.outputs.targets), 'backend')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build image
|
||||
run: docker build -t halobestie-backend:${{ inputs.environment }} ./backend
|
||||
- name: Export image as tarball
|
||||
run: docker save halobestie-backend:${{ inputs.environment }} | gzip > halobestie-backend-${{ inputs.environment }}.tar.gz
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: backend-${{ inputs.environment }}-image
|
||||
path: halobestie-backend-${{ inputs.environment }}.tar.gz
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
22
backend/.dockerignore
Normal file
22
backend/.dockerignore
Normal file
@@ -0,0 +1,22 @@
|
||||
# Deps are reinstalled inside the image via `npm ci`
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
|
||||
# Secrets / local env — mounted at runtime, never baked in
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# VCS + tooling
|
||||
.git
|
||||
.gitignore
|
||||
.vscode
|
||||
.idea
|
||||
|
||||
# Tests + coverage (not needed in the runtime image)
|
||||
coverage
|
||||
docker-compose*.yml
|
||||
**/*.test.js
|
||||
|
||||
# Docs
|
||||
*.md
|
||||
@@ -10,7 +10,7 @@ Fastify.js REST API serving both mobile apps and the internal control center.
|
||||
- **Database:** PostgreSQL via GCP Cloud SQL
|
||||
- **Auth:** Self-managed JWT (HS256 access, 1h) + opaque refresh token (30d, rotated, bcrypt-hashed in `auth_sessions`). Firebase Auth removed in Phase 3.4 (commit `f860ab6`). `firebase-admin` is kept but only for FCM messaging.
|
||||
- **Payment:** Xendit
|
||||
- **Infra:** GCP Cloud Run
|
||||
- **Infra:** Self-hosted Docker (VPS / Kubernetes / Docker Engine) — **not** Cloud Run. Multi-stage [Dockerfile](Dockerfile); deploy + log runbook in [DEPLOY.md](DEPLOY.md). DB is PostgreSQL (managed or self-hosted).
|
||||
|
||||
## Two Listeners
|
||||
|
||||
|
||||
209
backend/DEPLOY.md
Normal file
209
backend/DEPLOY.md
Normal file
@@ -0,0 +1,209 @@
|
||||
# Backend — Docker Deployment Guide
|
||||
|
||||
Operational guide for building, deploying, and observing the Halo Bestie backend as a **Docker container** on self-hosted infra (VPS, Docker Engine, or Kubernetes). **Not** Cloud Run / serverless.
|
||||
|
||||
> Architecture / env-var reference: [../requirement/deployment.md](../requirement/deployment.md) · [.env.example](.env.example)
|
||||
|
||||
---
|
||||
|
||||
## 1. What gets deployed
|
||||
|
||||
A single image (multi-stage [Dockerfile](Dockerfile)) running `node src/server.js`, which starts **two listeners** ([src/server.js](src/server.js)):
|
||||
|
||||
| Listener | Bind | Port | Exposed? |
|
||||
|---|---|---|---|
|
||||
| Public API (`client_app` + `mitra_app`) | `0.0.0.0` | `PUBLIC_PORT` (default **3000**) | **Yes** — publish this |
|
||||
| Internal API (control center) | `INTERNAL_HOST` (default `127.0.0.1`) | `INTERNAL_PORT` (default 3001) | **No** — loopback only, never publish |
|
||||
|
||||
Runtime image: Node 20 (bookworm-slim), prod-only deps, runs as non-root `node`, native `bcrypt` precompiled in the build stage.
|
||||
|
||||
---
|
||||
|
||||
## 2. Install Docker (one-time, on the host)
|
||||
|
||||
### Ubuntu / Debian VPS
|
||||
```bash
|
||||
# Remove any old packages, then install Docker Engine from the official repo
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
|
||||
# Run docker as your user without sudo (log out/in afterward)
|
||||
sudo usermod -aG docker "$USER"
|
||||
|
||||
# Verify
|
||||
docker version
|
||||
docker compose version # Compose v2 ships as a plugin with modern Docker
|
||||
```
|
||||
|
||||
### Kubernetes
|
||||
No Docker Engine needed on nodes — just push the image to a registry your cluster can pull from (see §4) and apply your manifests (§7).
|
||||
|
||||
---
|
||||
|
||||
## 3. Configure environment
|
||||
|
||||
Create `backend/.env.staging` (or `.env.production`) from the template — **never commit it**:
|
||||
```bash
|
||||
cp .env.example .env.staging
|
||||
```
|
||||
Fill in at minimum (full list in [.env.example](.env.example)):
|
||||
|
||||
| Var | Notes |
|
||||
|---|---|
|
||||
| `PUBLIC_PORT` | `3000` (keep default unless your proxy expects otherwise) |
|
||||
| `INTERNAL_HOST` / `INTERNAL_PORT` | leave default `127.0.0.1:3001` — keeps control center private |
|
||||
| `DATABASE_URL` | Postgres connection string |
|
||||
| `VALKEY_URL` | `redis://<host>:6379` |
|
||||
| `SERVER_TZ` | `UTC` |
|
||||
| `AUTH_JWT_SECRET` | **fresh per environment** — never reuse prod's |
|
||||
| `FIREBASE_SERVICE_ACCOUNT_PATH` | path to the **mounted** SA JSON, e.g. `/secrets/firebase-sa.json`. Must be from the env's Firebase project (staging = `my-bestie-876ec`) |
|
||||
| `XENDIT_ENABLED` | `false` until test keys + webhook are wired |
|
||||
| `CC_ORIGIN`, `ADMIN_EMAIL`, `ADMIN_PASSWORD` | control-center access |
|
||||
|
||||
> Secrets (`.env`, the Firebase SA JSON) are provided at **runtime** via `--env-file` / volume mounts / k8s Secrets. They are **not** baked into the image (`.dockerignore` excludes `.env*`).
|
||||
|
||||
---
|
||||
|
||||
## 4. Build & push the image
|
||||
|
||||
```bash
|
||||
# From the repo root
|
||||
docker build -t <registry>/halobestie-backend:staging ./backend
|
||||
|
||||
# Push to your registry (Docker Hub, GHCR, GCP Artifact Registry, self-hosted, …)
|
||||
docker push <registry>/halobestie-backend:staging
|
||||
```
|
||||
For a purely single-host setup you can skip the registry and build directly on the host.
|
||||
|
||||
---
|
||||
|
||||
## 5. Run database migrations (one-off)
|
||||
|
||||
Run **before** (re)starting the service. Never auto-migrate on container boot — concurrent replicas would race.
|
||||
```bash
|
||||
# Migrate (every deploy that includes new migrations)
|
||||
docker run --rm --env-file backend/.env.staging \
|
||||
<registry>/halobestie-backend:staging node src/db/migrate.js
|
||||
|
||||
# Seed (first deploy only)
|
||||
docker run --rm --env-file backend/.env.staging \
|
||||
<registry>/halobestie-backend:staging node src/db/seed.js
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Deploy — plain Docker Engine
|
||||
|
||||
```bash
|
||||
docker run -d --name halobestie-staging \
|
||||
--env-file backend/.env.staging \
|
||||
-p 3000:3000 \
|
||||
-v /opt/halobestie/secrets/firebase-sa.json:/secrets/firebase-sa.json:ro \
|
||||
--restart unless-stopped \
|
||||
--log-driver json-file --log-opt max-size=10m --log-opt max-file=5 \
|
||||
<registry>/halobestie-backend:staging
|
||||
```
|
||||
- Publish **only** `3000`. Do **not** map `3001`.
|
||||
- `--log-opt` enables log rotation — see §8.
|
||||
- Put a TLS-terminating reverse proxy (Nginx / Traefik / Caddy) in front for `https://staging-api.halobestie.com`. WebSocket upgrade must be proxied (the apps use `/api/shared/ws`).
|
||||
|
||||
### Or with Docker Compose
|
||||
A ready-to-use [docker-compose.staging.yml](docker-compose.staging.yml) is included (backend only — Postgres/Valkey are expected via `DATABASE_URL`/`VALKEY_URL`). It publishes only `3000`, mounts the Firebase SA + log volume, and sets json-file rotation. Point it at your image via the `BACKEND_IMAGE` env var (or uncomment `build: .` to build on the host):
|
||||
```bash
|
||||
cd backend
|
||||
cp .env.example .env.staging # then fill it in
|
||||
BACKEND_IMAGE=<registry>/halobestie-backend:staging \
|
||||
docker compose -f docker-compose.staging.yml up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Deploy — Kubernetes (sketch)
|
||||
|
||||
- **Deployment** with the image, `envFrom` a Secret/ConfigMap, the Firebase SA JSON mounted from a Secret volume at `/secrets/firebase-sa.json`.
|
||||
- **Service** exposing only container port `3000`. Liveness/readiness probes: `tcpSocket: { port: 3000 }` (no HTTP health route — TCP probe matches the Dockerfile HEALTHCHECK).
|
||||
- Migrations: a one-off **Job** (`command: ["node","src/db/migrate.js"]`) run before rolling out, not an initContainer on every pod.
|
||||
- Logs: pods write to stdout (§8) — your cluster's node agent (Fluent Bit / Loki / Cloud Logging) collects them automatically.
|
||||
|
||||
---
|
||||
|
||||
## 8. Logs — where they go and how to map them
|
||||
|
||||
### 8a. Application logs → **stdout/stderr** (the Docker-native way)
|
||||
The backend uses Fastify's pino logger (`logger: true` in [src/app.public.js](src/app.public.js) / [src/app.internal.js](src/app.internal.js)), emitting **structured JSON to stdout**, plus a few `console.log` lifecycle lines. It does **not** write its own app-log files. So:
|
||||
|
||||
```bash
|
||||
# Tail live
|
||||
docker logs -f halobestie-staging
|
||||
|
||||
# Last 200 lines
|
||||
docker logs --tail 200 halobestie-staging
|
||||
|
||||
# Pretty-print the JSON (pino output is one JSON object per line)
|
||||
docker logs -f halobestie-staging | jq .
|
||||
|
||||
# Compose equivalent
|
||||
docker compose -f docker-compose.staging.yml logs -f backend
|
||||
```
|
||||
|
||||
**Rotation (important — default json-file logs grow unbounded):**
|
||||
set it per-container as in §6 (`--log-opt max-size=10m --log-opt max-file=5`), or globally in `/etc/docker/daemon.json`:
|
||||
```json
|
||||
{
|
||||
"log-driver": "json-file",
|
||||
"log-opts": { "max-size": "10m", "max-file": "5" }
|
||||
}
|
||||
```
|
||||
then `sudo systemctl restart docker`.
|
||||
|
||||
**Ship logs off-host (optional):** point Docker at a log driver instead of/alongside json-file — e.g. `--log-driver=loki`, `--log-driver=fluentd`, `--log-driver=syslog`, or `--log-driver=gcplogs`. On k8s, stdout is collected by the node logging agent; no per-container config needed.
|
||||
|
||||
**Persist raw stdout to a host file (simple VPS option):**
|
||||
```bash
|
||||
docker run -d --name halobestie-staging ... \
|
||||
<image> > /var/log/halobestie/backend.log 2>&1
|
||||
# better: use the json-file driver (above) and read /var/lib/docker/containers/<id>/<id>-json.log,
|
||||
# or redirect via your reverse proxy / a sidecar. Prefer a real log driver for rotation.
|
||||
```
|
||||
|
||||
### 8b. Xendit webhook fallback JSONL → **needs a volume** (only if enabled)
|
||||
The one component that writes a **file** is the optional webhook fallback sink ([src/services/webhook-log.service.js](src/services/webhook-log.service.js)), **off by default**. When `XENDIT_WEBHOOK_FALLBACK_ENABLED=true`, it writes rolling JSONL to `XENDIT_WEBHOOK_FALLBACK_DIR` (default `./logs` → `/app/logs` in the container). To keep those across restarts, **mount a volume**:
|
||||
```bash
|
||||
docker run -d ... \
|
||||
-e XENDIT_WEBHOOK_FALLBACK_ENABLED=true \
|
||||
-e XENDIT_WEBHOOK_FALLBACK_DIR=/app/logs \
|
||||
-v /opt/halobestie/logs:/app/logs \
|
||||
<image>
|
||||
```
|
||||
(The Compose example in §6 already declares the `backend-logs` volume for this.) If the fallback stays disabled, you don't need this volume — everything is on stdout.
|
||||
|
||||
---
|
||||
|
||||
## 9. Health, upgrade, rollback
|
||||
|
||||
```bash
|
||||
# Health — the image has a built-in TCP HEALTHCHECK; check it:
|
||||
docker inspect --format '{{.State.Health.Status}}' halobestie-staging
|
||||
|
||||
# Upgrade to a new image
|
||||
docker pull <registry>/halobestie-backend:staging
|
||||
docker run ... node src/db/migrate.js # if new migrations
|
||||
docker stop halobestie-staging && docker rm halobestie-staging
|
||||
docker run -d --name halobestie-staging ... # re-run with the new image
|
||||
|
||||
# Rollback = re-run the previous tag/digest. Graceful shutdown is handled:
|
||||
# server.js traps SIGTERM and drains the listeners before exit.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Quick reference
|
||||
|
||||
| Task | Command |
|
||||
|---|---|
|
||||
| Build | `docker build -t <img> ./backend` |
|
||||
| Migrate | `docker run --rm --env-file .env.staging <img> node src/db/migrate.js` |
|
||||
| Run | `docker run -d --name halobestie-staging --env-file .env.staging -p 3000:3000 --restart unless-stopped <img>` |
|
||||
| Logs (live) | `docker logs -f halobestie-staging` |
|
||||
| Logs (pretty) | `docker logs -f halobestie-staging \| jq .` |
|
||||
| Health | `docker inspect --format '{{.State.Health.Status}}' halobestie-staging` |
|
||||
| Shell in | `docker exec -it halobestie-staging sh` |
|
||||
44
backend/Dockerfile
Normal file
44
backend/Dockerfile
Normal file
@@ -0,0 +1,44 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 1 — builder: install production deps, compiling native addons (bcrypt)
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM node:20-bookworm-slim AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Toolchain required to compile native modules (bcrypt) when no prebuilt
|
||||
# binary matches the platform. Lives only in this stage.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 make g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install against the lockfile for reproducible builds. Copy manifests first
|
||||
# so this layer caches until deps actually change.
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 2 — runtime: slim image with only prod node_modules + app source
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM node:20-bookworm-slim AS runtime
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app
|
||||
|
||||
# Compiled node_modules (same base image → ABI-compatible bcrypt .node binary).
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY package.json ./
|
||||
COPY src ./src
|
||||
|
||||
# Drop privileges — node:* images ship a non-root `node` user.
|
||||
USER node
|
||||
|
||||
# Public listener only. INTERNAL_PORT (3001) binds to 127.0.0.1 inside the
|
||||
# container by default and is intentionally NOT published.
|
||||
EXPOSE 3000
|
||||
|
||||
# No HTTP health route exists — probe the TCP port directly so the check is
|
||||
# route-agnostic. Orchestrators (k8s) can override with their own probes.
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
|
||||
CMD node -e "require('net').connect({port: process.env.PUBLIC_PORT||3000, host:'127.0.0.1'}).on('connect',()=>process.exit(0)).on('error',()=>process.exit(1))"
|
||||
|
||||
CMD ["node", "src/server.js"]
|
||||
39
backend/docker-compose.staging.yml
Normal file
39
backend/docker-compose.staging.yml
Normal file
@@ -0,0 +1,39 @@
|
||||
# Staging deploy for the Halo Bestie backend (self-hosted Docker).
|
||||
# Usage:
|
||||
# cd backend
|
||||
# docker compose -f docker-compose.staging.yml up -d
|
||||
#
|
||||
# Prereqs: a populated .env.staging (cp .env.example .env.staging) and the
|
||||
# Firebase service-account JSON at the mounted host path below. See DEPLOY.md.
|
||||
#
|
||||
# This runs ONLY the backend. Postgres + Valkey are expected to be reachable
|
||||
# via DATABASE_URL / VALKEY_URL in .env.staging (managed/self-hosted elsewhere).
|
||||
# TLS termination + the public hostname are handled by a reverse proxy in front.
|
||||
|
||||
services:
|
||||
backend:
|
||||
image: ${BACKEND_IMAGE:-halobestie-backend:staging}
|
||||
# To build on the host instead of pulling a pushed image, comment out
|
||||
# `image:` above and uncomment:
|
||||
# build: .
|
||||
container_name: halobestie-staging
|
||||
env_file: .env.staging
|
||||
ports:
|
||||
- "3000:3000" # public listener only — never publish 3001
|
||||
volumes:
|
||||
# Firebase service-account JSON (must match the env's Firebase project,
|
||||
# staging = my-bestie-876ec). FIREBASE_SERVICE_ACCOUNT_PATH in .env.staging
|
||||
# must equal the in-container path on the right.
|
||||
- /opt/halobestie/secrets/firebase-sa.json:/secrets/firebase-sa.json:ro
|
||||
# Optional: only needed if XENDIT_WEBHOOK_FALLBACK_ENABLED=true (writes
|
||||
# rolling JSONL to /app/logs). App logs themselves go to stdout — see DEPLOY.md §8.
|
||||
- backend-logs:/app/logs
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "5"
|
||||
|
||||
volumes:
|
||||
backend-logs:
|
||||
@@ -4,6 +4,9 @@
|
||||
"description": "Halo Bestie backend API",
|
||||
"main": "src/server.js",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "node --watch src/server.js",
|
||||
"start": "node src/server.js",
|
||||
|
||||
184
client_app/BUILD_FLAVORS.md
Normal file
184
client_app/BUILD_FLAVORS.md
Normal file
@@ -0,0 +1,184 @@
|
||||
# Build Flavors — client_app (Android + iOS identity)
|
||||
|
||||
Three build flavors map each environment to its own app ID, backend URL, app
|
||||
display name, and Firebase config. This lets dev / staging / prod builds sit
|
||||
**side-by-side** on one device (distinct package / bundle IDs).
|
||||
|
||||
> Scope: **Android flavors + Dart + env files are fully wired.** On iOS, only
|
||||
> the **bundle identity** is set (see the iOS section below); the iOS **Xcode
|
||||
> build-configuration + scheme wiring** (per-flavor bundle suffixing,
|
||||
> per-scheme `GoogleService-Info.plist`) is a separate follow-up that needs a
|
||||
> Mac and is NOT done here.
|
||||
|
||||
## Flavor table
|
||||
|
||||
| Flavor | applicationId | App name | API_BASE_URL |
|
||||
|----------|-------------------------|--------------------|-------------------------------------------|
|
||||
| dev | `com.mybestie.dev` | HaloBestie Dev | `http://192.168.88.247:3000` |
|
||||
| staging | `com.mybestie.staging` | HaloBestie Staging | `https://staging-api.halobestie.com` ⚠️\* |
|
||||
| prod | `com.mybestie` | HaloBestie | `https://api.halobestie.com` |
|
||||
|
||||
\* staging URL is a **PLACEHOLDER** — confirm the real staging host and update
|
||||
`env/staging.json` (see the `_TODO` key there).
|
||||
|
||||
The `applicationId` is set in `android/app/build.gradle.kts` via
|
||||
`applicationIdSuffix` (dev/staging) on top of the base `com.mybestie`; prod has
|
||||
no suffix. The app name comes from a per-flavor `resValue("string","app_name",…)`
|
||||
consumed by `android:label="@string/app_name"` in `AndroidManifest.xml`.
|
||||
|
||||
## iOS bundle identity — DIFFERENT base from Android ⚠️
|
||||
|
||||
The customer app already exists on the App Store under bundle ID
|
||||
**`com.asc.hallobestie`** (published by a prior vendor — note the `asc` prefix
|
||||
and `hallobestie` spelling). **Decision: v2 ships as an _update_ to that existing
|
||||
listing**, so the iOS **prod** bundle ID must stay `com.asc.hallobestie` (you
|
||||
cannot change a published app's bundle ID without it becoming a new app). The
|
||||
iOS flavors therefore suffix **that** base, NOT the Android `com.mybestie` base:
|
||||
|
||||
| Flavor | Android `applicationId` | iOS bundle ID |
|
||||
|---------|-------------------------|--------------------------------|
|
||||
| dev | `com.mybestie.dev` | `com.asc.hallobestie.dev` |
|
||||
| staging | `com.mybestie.staging` | `com.asc.hallobestie.staging` |
|
||||
| prod | `com.mybestie` | `com.asc.hallobestie` |
|
||||
|
||||
A bundle ID differing across platforms is normal. What's done so far on iOS:
|
||||
- `ios/Runner.xcodeproj/project.pbxproj` — base `PRODUCT_BUNDLE_IDENTIFIER` set
|
||||
to `com.asc.hallobestie` (+ `com.asc.hallobestie.RunnerTests`). Once iOS
|
||||
schemes are wired, dev/staging suffix off this base.
|
||||
- `firebase_options_{dev,staging,prod}.dart` — `iosBundleId` set to the table
|
||||
above. dev's iOS appId/apiKey are now PLACEHOLDERs (the old `com.mybestie`
|
||||
iOS app no longer matches) — register `com.asc.hallobestie.dev` in the dev
|
||||
project and paste the values.
|
||||
- `ios/Runner/Info.plist` — `CFBundleURLName` aligned to `com.asc.hallobestie`
|
||||
(the `halobestie://` deeplink scheme itself is unchanged).
|
||||
|
||||
Still TODO on iOS (the deferred Mac/Xcode work):
|
||||
- The existing `ios/Runner/GoogleService-Info.plist` still lists `com.mybestie`
|
||||
— replace per flavor once the iOS apps are registered.
|
||||
- Xcode build configs + schemes to actually apply the `.dev`/`.staging` bundle
|
||||
suffixes per build, with a copy-script selecting the right `GoogleService-Info.plist`.
|
||||
- You need access to the **`asc` Apple Developer account** + its signing
|
||||
certs/provisioning profiles to release the prod update.
|
||||
|
||||
> **mitra app is unaffected** — it's a brand-new app (`com.mybestie.mitra` +
|
||||
> suffixes) on both platforms; only the customer iOS app inherits the legacy
|
||||
> `com.asc.hallobestie` identity.
|
||||
|
||||
## How the pieces fit
|
||||
|
||||
| Concern | dev | staging | prod |
|
||||
|----------------------|---------------------------------------|--------------------------------------------|-----------------------------------------|
|
||||
| Dart entrypoint | `lib/main_dev.dart` | `lib/main_staging.dart` | `lib/main_prod.dart` |
|
||||
| Firebase Dart opts | `lib/firebase/firebase_options_dev.dart` | `…_staging.dart` (PLACEHOLDER) | `…_prod.dart` (PLACEHOLDER) |
|
||||
| Android Firebase cfg | `android/app/src/dev/google-services.json` | `android/app/src/staging/…` (MISSING) | `android/app/src/prod/…` (MISSING) |
|
||||
| Env / dart-define | `env/dev.json` | `env/staging.json` | `env/prod.json` |
|
||||
|
||||
A bare `flutter run` (no `-t`) still works and defaults to **dev**:
|
||||
`lib/main.dart`'s `main()` delegates to `bootstrap(flavor: 'dev', …)`.
|
||||
|
||||
## Run / build commands
|
||||
|
||||
Each command needs three things: `--flavor <f>`, `-t lib/main_<f>.dart`, and
|
||||
`--dart-define-from-file=env/<f>.json`.
|
||||
|
||||
### Run on a connected device/emulator
|
||||
```bash
|
||||
# dev
|
||||
flutter run --flavor dev -t lib/main_dev.dart --dart-define-from-file=env/dev.json
|
||||
|
||||
# staging
|
||||
flutter run --flavor staging -t lib/main_staging.dart --dart-define-from-file=env/staging.json
|
||||
|
||||
# prod
|
||||
flutter run --flavor prod -t lib/main_prod.dart --dart-define-from-file=env/prod.json
|
||||
```
|
||||
|
||||
### Build a release APK
|
||||
```bash
|
||||
# dev
|
||||
flutter build apk --flavor dev -t lib/main_dev.dart --dart-define-from-file=env/dev.json
|
||||
|
||||
# staging
|
||||
flutter build apk --flavor staging -t lib/main_staging.dart --dart-define-from-file=env/staging.json
|
||||
|
||||
# prod
|
||||
flutter build apk --flavor prod -t lib/main_prod.dart --dart-define-from-file=env/prod.json
|
||||
```
|
||||
|
||||
### Build an App Bundle (Play release)
|
||||
```bash
|
||||
flutter build appbundle --flavor prod -t lib/main_prod.dart --dart-define-from-file=env/prod.json
|
||||
```
|
||||
|
||||
Output APKs land in `build/app/outputs/flutter-apk/app-<flavor>-release.apk`.
|
||||
|
||||
## ⚠️ CRITICAL warnings
|
||||
|
||||
### (a) Every build/install/run command now needs `--flavor`
|
||||
Once product flavors exist, a bare `flutter build apk` / `flutter install` /
|
||||
`flutter run` **with no `--flavor`** FAILS (Gradle can't pick a flavor). All
|
||||
build, install, and run invocations — CI scripts, local muscle memory, IDE
|
||||
launch configs — must pass `--flavor <dev|staging|prod>` plus the matching
|
||||
`-t lib/main_<flavor>.dart` and `--dart-define-from-file=env/<flavor>.json`.
|
||||
|
||||
### (b) The dev applicationId is now `com.mybestie.dev`, not `com.mybestie`
|
||||
Anything that references the package name by string must be updated for dev:
|
||||
|
||||
- **GA4 DebugView:**
|
||||
```bash
|
||||
adb shell setprop debug.firebase.analytics.app com.mybestie.dev
|
||||
```
|
||||
- **adb commands** (force-stop, pm clear, am start, grant, etc.):
|
||||
```bash
|
||||
adb shell am force-stop com.mybestie.dev
|
||||
adb shell pm clear com.mybestie.dev
|
||||
```
|
||||
- **Maestro flows:** any `appId: com.mybestie` must become `appId: com.mybestie.dev`
|
||||
for the dev build. Check `.maestro/` configs and per-flow `appId` headers.
|
||||
|
||||
Staging uses `com.mybestie.staging`; prod stays `com.mybestie`.
|
||||
|
||||
## Firebase config — STATUS: configured ✅ (2026-06-04)
|
||||
|
||||
All apps are registered and config files are in place for **both platforms**,
|
||||
across 3 projects (one Firebase project per env):
|
||||
|
||||
| Env | Firebase project | Android applicationId | iOS bundle ID |
|
||||
|---------|------------------------|------------------------|-------------------------------|
|
||||
| dev | `halobestie-clone-dev` | `com.mybestie.dev` | `com.asc.hallobestie.dev` |
|
||||
| staging | `my-bestie-876ec` | `com.mybestie.staging` | `com.asc.hallobestie.staging` |
|
||||
| prod | `my-bestie-production` | `com.mybestie` | `com.asc.hallobestie` |
|
||||
|
||||
In place and verified:
|
||||
- `android/app/src/<flavor>/google-services.json` — all 3, each containing a
|
||||
client matching the flavor `applicationId`.
|
||||
- `ios/config/<flavor>/GoogleService-Info.plist` — all 3, bundle IDs verified.
|
||||
- `lib/firebase/firebase_options_{dev,staging,prod}.dart` — real android + iOS
|
||||
values (regenerated from the native files above; no placeholders left).
|
||||
|
||||
### Regenerating after any ID / key change
|
||||
Either re-run flutterfire per flavor, or re-download the native files and
|
||||
re-extract. Project IDs:
|
||||
```bash
|
||||
flutterfire configure --project=halobestie-clone-dev --out=lib/firebase/firebase_options_dev.dart
|
||||
flutterfire configure --project=my-bestie-876ec --out=lib/firebase/firebase_options_staging.dart
|
||||
flutterfire configure --project=my-bestie-production --out=lib/firebase/firebase_options_prod.dart
|
||||
```
|
||||
|
||||
### Still TODO — iOS only (Mac/Xcode)
|
||||
- [ ] iOS **Xcode schemes + build-phase copy script** to apply per-flavor bundle
|
||||
suffixes and select the right `GoogleService-Info.plist` at build time.
|
||||
Until then, iOS bundles only `ios/Runner/GoogleService-Info.plist`.
|
||||
- [ ] Replace `ios/Runner/GoogleService-Info.plist` (still the legacy
|
||||
`com.mybestie` dev file) — or let the copy script overwrite it per build.
|
||||
- [ ] Prod iOS release needs the **`asc` Apple Developer account** + signing.
|
||||
|
||||
## Files in this setup
|
||||
|
||||
- `android/app/build.gradle.kts` — `flavorDimensions` + `productFlavors`
|
||||
- `android/app/src/main/AndroidManifest.xml` — `android:label="@string/app_name"`
|
||||
- `lib/main.dart` — `bootstrap()` + dev-default `main()`
|
||||
- `lib/main_dev.dart` / `lib/main_staging.dart` / `lib/main_prod.dart`
|
||||
- `lib/firebase/firebase_options_{dev,staging,prod}.dart`
|
||||
- `env/{dev,staging,prod}.json`
|
||||
- `android/app/src/{dev,staging,prod}/` Firebase config source sets
|
||||
@@ -24,7 +24,10 @@ android {
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
// Base application ID. Per-flavor suffixes below produce the final IDs:
|
||||
// dev -> com.mybestie.dev
|
||||
// staging -> com.mybestie.staging
|
||||
// prod -> com.mybestie
|
||||
applicationId = "com.mybestie"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
@@ -34,6 +37,34 @@ android {
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
// Build flavors for the three environments. Each flavor:
|
||||
// - sets its final applicationId (via suffix, except prod)
|
||||
// - injects an `app_name` string resource consumed by
|
||||
// AndroidManifest.xml's android:label="@string/app_name"
|
||||
// - selects its own Firebase config via the matching source set
|
||||
// (android/app/src/<flavor>/google-services.json)
|
||||
// NOTE: once these flavors exist, a bare `flutter build apk` (no --flavor)
|
||||
// fails. All build/install/run commands MUST pass --flavor. See
|
||||
// BUILD_FLAVORS.md.
|
||||
flavorDimensions += "env"
|
||||
productFlavors {
|
||||
create("dev") {
|
||||
dimension = "env"
|
||||
applicationIdSuffix = ".dev"
|
||||
resValue("string", "app_name", "HaloBestie Dev")
|
||||
}
|
||||
create("staging") {
|
||||
dimension = "env"
|
||||
applicationIdSuffix = ".staging"
|
||||
resValue("string", "app_name", "HaloBestie Staging")
|
||||
}
|
||||
create("prod") {
|
||||
dimension = "env"
|
||||
// No applicationIdSuffix -> final applicationId stays "com.mybestie".
|
||||
resValue("string", "app_name", "HaloBestie")
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
|
||||
@@ -62,6 +62,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:1068156046511:android:1f589ed358ccdad0b8185a",
|
||||
"android_client_info": {
|
||||
"package_name": "com.mybestie.dev"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyDFWlLSWytqwI7LSdUbVrO7J5De9L2LV2U"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:1068156046511:android:8a873c8b7e64410ab8185a",
|
||||
@@ -3,7 +3,7 @@
|
||||
Phase 4 Stage 4 notif-gate via permission_handler. -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<application
|
||||
android:label="HaloBestie"
|
||||
android:label="@string/app_name"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:usesCleartextTraffic="true">
|
||||
|
||||
70
client_app/android/app/src/prod/google-services.json
Executable file
70
client_app/android/app/src/prod/google-services.json
Executable file
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "953866659887",
|
||||
"project_id": "my-bestie-production",
|
||||
"storage_bucket": "my-bestie-production.firebasestorage.app"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:953866659887:android:55dfbf97ac7c26e7183eda",
|
||||
"android_client_info": {
|
||||
"package_name": "com.mybestie"
|
||||
}
|
||||
},
|
||||
"oauth_client": [
|
||||
{
|
||||
"client_id": "953866659887-5a62u0tdce92i0gmfo0gf3dt0dnlre42.apps.googleusercontent.com",
|
||||
"client_type": 1,
|
||||
"android_info": {
|
||||
"package_name": "com.mybestie",
|
||||
"certificate_hash": "3c43db3c9ac7f6d7e2fa03b8dbcaf7e5d12c97f3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_id": "953866659887-kebg3eijcomtv97q6v03fm8i30kj7r9r.apps.googleusercontent.com",
|
||||
"client_type": 1,
|
||||
"android_info": {
|
||||
"package_name": "com.mybestie",
|
||||
"certificate_hash": "7119b6cf7091074759450c899191905a5a4d0369"
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_id": "953866659887-vvur2mnmbu8ljmnmmg01hqsrj0ocssu9.apps.googleusercontent.com",
|
||||
"client_type": 1,
|
||||
"android_info": {
|
||||
"package_name": "com.mybestie",
|
||||
"certificate_hash": "6e23f0164af04ddf200f769c460caac3ee2b91ac"
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_id": "953866659887-9ro36er68qupv00rgokjignnu2hs85v8.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
}
|
||||
],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyAxAp8hcXO-P7HwwVsS3vFe0OX5ZkIyyWI"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": [
|
||||
{
|
||||
"client_id": "953866659887-9ro36er68qupv00rgokjignnu2hs85v8.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
},
|
||||
{
|
||||
"client_id": "953866659887-bsb3c2a6ir10u47q8vcacre2tmnk59jb.apps.googleusercontent.com",
|
||||
"client_type": 2,
|
||||
"ios_info": {
|
||||
"bundle_id": "com.asc.hallobestie"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
36
client_app/android/app/src/prod/google-services.json.README
Normal file
36
client_app/android/app/src/prod/google-services.json.README
Normal file
@@ -0,0 +1,36 @@
|
||||
PROD google-services.json — NOT YET PRESENT
|
||||
===========================================
|
||||
|
||||
The Gradle google-services plugin reads this flavor's Firebase config from:
|
||||
|
||||
android/app/src/prod/google-services.json <-- MISSING
|
||||
|
||||
This file is deliberately a README, NOT a fabricated google-services.json.
|
||||
A wrong/placeholder google-services.json compiles fine but SILENTLY breaks
|
||||
Firebase Analytics and FCM at runtime (no build error, no crash — just no
|
||||
events / no push). Do not invent one.
|
||||
|
||||
How to produce the real file
|
||||
----------------------------
|
||||
1. In the Firebase Console for the PRODUCTION project, register an Android app
|
||||
with package name:
|
||||
|
||||
com.mybestie
|
||||
|
||||
(no suffix — prod is the un-suffixed applicationId). Use the SEPARATE prod
|
||||
Firebase console, NOT halobestie-clone-dev. See BUILD_FLAVORS.md.
|
||||
|
||||
2. Download the generated google-services.json.
|
||||
|
||||
3. Drop it here, replacing this README:
|
||||
|
||||
android/app/src/prod/google-services.json
|
||||
|
||||
4. Also generate the Dart options:
|
||||
|
||||
flutterfire configure --project=<prod-project> \
|
||||
--out=lib/firebase/firebase_options_prod.dart
|
||||
|
||||
(firebase_options_prod.dart currently holds PLACEHOLDER values.)
|
||||
|
||||
Until both files hold real values, do NOT release a production build.
|
||||
106
client_app/android/app/src/staging/google-services.json
Executable file
106
client_app/android/app/src/staging/google-services.json
Executable file
@@ -0,0 +1,106 @@
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "650461407929",
|
||||
"project_id": "my-bestie-876ec",
|
||||
"storage_bucket": "my-bestie-876ec.firebasestorage.app"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:650461407929:android:92d95eb766802bcf504968",
|
||||
"android_client_info": {
|
||||
"package_name": "com.mybestie"
|
||||
}
|
||||
},
|
||||
"oauth_client": [
|
||||
{
|
||||
"client_id": "650461407929-ofuff9cv8d4fj79efj9e2ovt2nj9b83a.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
}
|
||||
],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyAOPkPJkHXLFzo9ICOHyjee2Vn_EUqt1Pc"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": [
|
||||
{
|
||||
"client_id": "650461407929-ofuff9cv8d4fj79efj9e2ovt2nj9b83a.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
},
|
||||
{
|
||||
"client_id": "650461407929-2o7eo5ts2k389pa3l16sq26l3107b52f.apps.googleusercontent.com",
|
||||
"client_type": 2,
|
||||
"ios_info": {
|
||||
"bundle_id": "com.example.halloBestie"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:650461407929:android:05754df9552e0529504968",
|
||||
"android_client_info": {
|
||||
"package_name": "com.mybestie.staging"
|
||||
}
|
||||
},
|
||||
"oauth_client": [
|
||||
{
|
||||
"client_id": "650461407929-3els6l9cegtiphe930vtjr80ffa2p1uv.apps.googleusercontent.com",
|
||||
"client_type": 1,
|
||||
"android_info": {
|
||||
"package_name": "com.mybestie.staging",
|
||||
"certificate_hash": "a46c19a615b3c21b529240dabc8f1cd68bcbd449"
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_id": "650461407929-8lo71sr668gvvj0ntpjjemoqrkr82uid.apps.googleusercontent.com",
|
||||
"client_type": 1,
|
||||
"android_info": {
|
||||
"package_name": "com.mybestie.staging",
|
||||
"certificate_hash": "6e23f0164af04ddf200f769c460caac3ee2b91ac"
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_id": "650461407929-lmj6n5jt818fkdjhjpbhabdd19g82f48.apps.googleusercontent.com",
|
||||
"client_type": 1,
|
||||
"android_info": {
|
||||
"package_name": "com.mybestie.staging",
|
||||
"certificate_hash": "937ecfa181a695a5f1fb5d04df15e490c174caea"
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_id": "650461407929-ofuff9cv8d4fj79efj9e2ovt2nj9b83a.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
}
|
||||
],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyAOPkPJkHXLFzo9ICOHyjee2Vn_EUqt1Pc"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": [
|
||||
{
|
||||
"client_id": "650461407929-ofuff9cv8d4fj79efj9e2ovt2nj9b83a.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
},
|
||||
{
|
||||
"client_id": "650461407929-2o7eo5ts2k389pa3l16sq26l3107b52f.apps.googleusercontent.com",
|
||||
"client_type": 2,
|
||||
"ios_info": {
|
||||
"bundle_id": "com.example.halloBestie"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
STAGING google-services.json — NOT YET PRESENT
|
||||
==============================================
|
||||
|
||||
The Gradle google-services plugin reads this flavor's Firebase config from:
|
||||
|
||||
android/app/src/staging/google-services.json <-- MISSING
|
||||
|
||||
This file is deliberately a README, NOT a fabricated google-services.json.
|
||||
A wrong/placeholder google-services.json compiles fine but SILENTLY breaks
|
||||
Firebase Analytics and FCM at runtime (no build error, no crash — just no
|
||||
events / no push). Do not invent one.
|
||||
|
||||
How to produce the real file
|
||||
----------------------------
|
||||
1. In the Firebase Console for the STAGING project, register an Android app
|
||||
with package name:
|
||||
|
||||
com.mybestie.staging
|
||||
|
||||
(this is the dev/staging Firebase project today — see BUILD_FLAVORS.md for
|
||||
the "register com.mybestie.staging in halobestie-clone-dev" decision, or a
|
||||
dedicated staging project if/when one exists).
|
||||
|
||||
2. Download the generated google-services.json.
|
||||
|
||||
3. Drop it here, replacing this README:
|
||||
|
||||
android/app/src/staging/google-services.json
|
||||
|
||||
4. Also generate the Dart options:
|
||||
|
||||
flutterfire configure --project=<staging-project> \
|
||||
--out=lib/firebase/firebase_options_staging.dart
|
||||
|
||||
(firebase_options_staging.dart currently holds PLACEHOLDER values.)
|
||||
|
||||
Until both files hold real values, do NOT distribute a staging build.
|
||||
4
client_app/env/dev.json
vendored
Normal file
4
client_app/env/dev.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"API_BASE_URL": "http://192.168.88.247:3000",
|
||||
"FLAVOR": "dev"
|
||||
}
|
||||
4
client_app/env/prod.json
vendored
Normal file
4
client_app/env/prod.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"API_BASE_URL": "https://api.halobestie.com",
|
||||
"FLAVOR": "prod"
|
||||
}
|
||||
5
client_app/env/staging.json
vendored
Normal file
5
client_app/env/staging.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"API_BASE_URL": "https://staging-api.halobestie.com",
|
||||
"FLAVOR": "staging",
|
||||
"_TODO": "confirm staging URL — https://staging-api.halobestie.com is a PLACEHOLDER (JSON forbids comments; this key documents the TODO and is ignored by the app)"
|
||||
}
|
||||
@@ -496,7 +496,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.mybestie;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.asc.hallobestie;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -513,7 +513,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.mybestie.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.asc.hallobestie.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
@@ -531,7 +531,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.mybestie.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.asc.hallobestie.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
@@ -547,7 +547,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.mybestie.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.asc.hallobestie.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
@@ -679,7 +679,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.mybestie;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.asc.hallobestie;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
@@ -702,7 +702,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.mybestie;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.asc.hallobestie;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.mybestie</string>
|
||||
<string>com.asc.hallobestie</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>halobestie</string>
|
||||
|
||||
34
client_app/ios/config/README.md
Normal file
34
client_app/ios/config/README.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# iOS per-flavor Firebase config — client_app
|
||||
|
||||
iOS has **no** automatic per-flavor resolution (unlike Android's
|
||||
`android/app/src/<flavor>/google-services.json`). Stage the three
|
||||
`GoogleService-Info.plist` files here, one per flavor:
|
||||
|
||||
```
|
||||
ios/config/
|
||||
dev/GoogleService-Info.plist → bundle com.asc.hallobestie.dev (dev project)
|
||||
staging/GoogleService-Info.plist → bundle com.asc.hallobestie.staging (staging/nonprod)
|
||||
prod/GoogleService-Info.plist → bundle com.asc.hallobestie (prod project, live App Store app)
|
||||
```
|
||||
|
||||
> The customer iOS bundle base is **`com.asc.hallobestie`** (the existing App
|
||||
> Store app), NOT the Android `com.mybestie`. See `../../BUILD_FLAVORS.md`.
|
||||
|
||||
## Wiring (Xcode — Mac follow-up, not done yet)
|
||||
|
||||
The active plist Xcode bundles is `ios/Runner/GoogleService-Info.plist`. To make
|
||||
it per-flavor, add a **Run Script** build phase to the Runner target, placed
|
||||
**before** "Compile Sources":
|
||||
|
||||
```bash
|
||||
cp "${PROJECT_DIR}/config/${FLAVOR}/GoogleService-Info.plist" \
|
||||
"${PROJECT_DIR}/Runner/GoogleService-Info.plist"
|
||||
```
|
||||
|
||||
`${FLAVOR}` is a per-scheme/configuration build setting you define when creating
|
||||
the dev/staging/prod schemes (e.g. `FLAVOR = dev` in the dev configuration).
|
||||
|
||||
### Single-env shortcut
|
||||
If you only need one env working (e.g. dev) before the full scheme setup, just
|
||||
drop that env's plist directly at `ios/Runner/GoogleService-Info.plist` — no
|
||||
script needed for a single environment.
|
||||
30
client_app/ios/config/dev/GoogleService-Info.plist
Executable file
30
client_app/ios/config/dev/GoogleService-Info.plist
Executable file
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>API_KEY</key>
|
||||
<string>AIzaSyAQnB5hbj0T5tE4JQZQ9Tx6Whp_u15obMI</string>
|
||||
<key>GCM_SENDER_ID</key>
|
||||
<string>1068156046511</string>
|
||||
<key>PLIST_VERSION</key>
|
||||
<string>1</string>
|
||||
<key>BUNDLE_ID</key>
|
||||
<string>com.asc.hallobestie.dev</string>
|
||||
<key>PROJECT_ID</key>
|
||||
<string>halobestie-clone-dev</string>
|
||||
<key>STORAGE_BUCKET</key>
|
||||
<string>halobestie-clone-dev.firebasestorage.app</string>
|
||||
<key>IS_ADS_ENABLED</key>
|
||||
<false></false>
|
||||
<key>IS_ANALYTICS_ENABLED</key>
|
||||
<false></false>
|
||||
<key>IS_APPINVITE_ENABLED</key>
|
||||
<true></true>
|
||||
<key>IS_GCM_ENABLED</key>
|
||||
<true></true>
|
||||
<key>IS_SIGNIN_ENABLED</key>
|
||||
<true></true>
|
||||
<key>GOOGLE_APP_ID</key>
|
||||
<string>1:1068156046511:ios:bc9098ffc2c2913ab8185a</string>
|
||||
</dict>
|
||||
</plist>
|
||||
15
client_app/ios/config/dev/GoogleService-Info.plist.README
Normal file
15
client_app/ios/config/dev/GoogleService-Info.plist.README
Normal file
@@ -0,0 +1,15 @@
|
||||
Place the DEV GoogleService-Info.plist here, named exactly:
|
||||
|
||||
GoogleService-Info.plist
|
||||
|
||||
Register this iOS app first:
|
||||
• Firebase project: halobestie-clone-dev (the DEV project)
|
||||
• iOS bundle ID: com.asc.hallobestie.dev
|
||||
Then download its GoogleService-Info.plist into this folder (delete this .README).
|
||||
|
||||
Also paste its appId/apiKey into lib/firebase/firebase_options_dev.dart
|
||||
(the iOS section currently holds PLACEHOLDER values).
|
||||
|
||||
How it gets used: a build-phase "Run Script" (added in Xcode — Mac follow-up)
|
||||
copies the per-flavor file into ios/Runner/GoogleService-Info.plist at build
|
||||
time. See ../README.md and BUILD_FLAVORS.md.
|
||||
36
client_app/ios/config/prod/GoogleService-Info.plist
Executable file
36
client_app/ios/config/prod/GoogleService-Info.plist
Executable file
@@ -0,0 +1,36 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CLIENT_ID</key>
|
||||
<string>953866659887-bsb3c2a6ir10u47q8vcacre2tmnk59jb.apps.googleusercontent.com</string>
|
||||
<key>REVERSED_CLIENT_ID</key>
|
||||
<string>com.googleusercontent.apps.953866659887-bsb3c2a6ir10u47q8vcacre2tmnk59jb</string>
|
||||
<key>ANDROID_CLIENT_ID</key>
|
||||
<string>953866659887-5a62u0tdce92i0gmfo0gf3dt0dnlre42.apps.googleusercontent.com</string>
|
||||
<key>API_KEY</key>
|
||||
<string>AIzaSyA8guPSD87eDLeCsH6jVd1n2_SI4_MaGNE</string>
|
||||
<key>GCM_SENDER_ID</key>
|
||||
<string>953866659887</string>
|
||||
<key>PLIST_VERSION</key>
|
||||
<string>1</string>
|
||||
<key>BUNDLE_ID</key>
|
||||
<string>com.asc.hallobestie</string>
|
||||
<key>PROJECT_ID</key>
|
||||
<string>my-bestie-production</string>
|
||||
<key>STORAGE_BUCKET</key>
|
||||
<string>my-bestie-production.firebasestorage.app</string>
|
||||
<key>IS_ADS_ENABLED</key>
|
||||
<false></false>
|
||||
<key>IS_ANALYTICS_ENABLED</key>
|
||||
<false></false>
|
||||
<key>IS_APPINVITE_ENABLED</key>
|
||||
<true></true>
|
||||
<key>IS_GCM_ENABLED</key>
|
||||
<true></true>
|
||||
<key>IS_SIGNIN_ENABLED</key>
|
||||
<true></true>
|
||||
<key>GOOGLE_APP_ID</key>
|
||||
<string>1:953866659887:ios:159fd11b1d2f3633183eda</string>
|
||||
</dict>
|
||||
</plist>
|
||||
16
client_app/ios/config/prod/GoogleService-Info.plist.README
Normal file
16
client_app/ios/config/prod/GoogleService-Info.plist.README
Normal file
@@ -0,0 +1,16 @@
|
||||
Place the PROD GoogleService-Info.plist here, named exactly:
|
||||
|
||||
GoogleService-Info.plist
|
||||
|
||||
Register this iOS app first:
|
||||
• Firebase project: the PRODUCTION project
|
||||
• iOS bundle ID: com.asc.hallobestie (the EXISTING App Store app — v2 ships
|
||||
as an update to it; needs the `asc` Apple Developer account)
|
||||
Then download its GoogleService-Info.plist into this folder (delete this .README).
|
||||
|
||||
Also paste its values into lib/firebase/firebase_options_prod.dart
|
||||
(currently all PLACEHOLDER).
|
||||
|
||||
How it gets used: a build-phase "Run Script" (added in Xcode — Mac follow-up)
|
||||
copies the per-flavor file into ios/Runner/GoogleService-Info.plist at build
|
||||
time. See ../README.md and BUILD_FLAVORS.md.
|
||||
36
client_app/ios/config/staging/GoogleService-Info.plist
Executable file
36
client_app/ios/config/staging/GoogleService-Info.plist
Executable file
@@ -0,0 +1,36 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CLIENT_ID</key>
|
||||
<string>650461407929-fb4t48nmguaslfvis7ebsea8vndro7ac.apps.googleusercontent.com</string>
|
||||
<key>REVERSED_CLIENT_ID</key>
|
||||
<string>com.googleusercontent.apps.650461407929-fb4t48nmguaslfvis7ebsea8vndro7ac</string>
|
||||
<key>ANDROID_CLIENT_ID</key>
|
||||
<string>650461407929-3els6l9cegtiphe930vtjr80ffa2p1uv.apps.googleusercontent.com</string>
|
||||
<key>API_KEY</key>
|
||||
<string>AIzaSyC_BewS88iaNsc9QdwsPzkV0sf9wUs4i_4</string>
|
||||
<key>GCM_SENDER_ID</key>
|
||||
<string>650461407929</string>
|
||||
<key>PLIST_VERSION</key>
|
||||
<string>1</string>
|
||||
<key>BUNDLE_ID</key>
|
||||
<string>com.asc.hallobestie.staging</string>
|
||||
<key>PROJECT_ID</key>
|
||||
<string>my-bestie-876ec</string>
|
||||
<key>STORAGE_BUCKET</key>
|
||||
<string>my-bestie-876ec.firebasestorage.app</string>
|
||||
<key>IS_ADS_ENABLED</key>
|
||||
<false></false>
|
||||
<key>IS_ANALYTICS_ENABLED</key>
|
||||
<false></false>
|
||||
<key>IS_APPINVITE_ENABLED</key>
|
||||
<true></true>
|
||||
<key>IS_GCM_ENABLED</key>
|
||||
<true></true>
|
||||
<key>IS_SIGNIN_ENABLED</key>
|
||||
<true></true>
|
||||
<key>GOOGLE_APP_ID</key>
|
||||
<string>1:650461407929:ios:4ee79d479b69d688504968</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,15 @@
|
||||
Place the STAGING GoogleService-Info.plist here, named exactly:
|
||||
|
||||
GoogleService-Info.plist
|
||||
|
||||
Register this iOS app first:
|
||||
• Firebase project: the STAGING / nonprod project
|
||||
• iOS bundle ID: com.asc.hallobestie.staging
|
||||
Then download its GoogleService-Info.plist into this folder (delete this .README).
|
||||
|
||||
Also paste its values into lib/firebase/firebase_options_staging.dart
|
||||
(currently all PLACEHOLDER).
|
||||
|
||||
How it gets used: a build-phase "Run Script" (added in Xcode — Mac follow-up)
|
||||
copies the per-flavor file into ios/Runner/GoogleService-Info.plist at build
|
||||
time. See ../README.md and BUILD_FLAVORS.md.
|
||||
96
client_app/lib/core/widgets/web_page_screen.dart
Normal file
96
client_app/lib/core/widgets/web_page_screen.dart
Normal file
@@ -0,0 +1,96 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
|
||||
import '../theme/halo_tokens.dart';
|
||||
|
||||
/// Generic in-app WebView host for static external pages (privacy policy,
|
||||
/// terms & conditions, etc).
|
||||
///
|
||||
/// Unlike [XenditCheckoutScreen] this carries no navigation-interception logic
|
||||
/// — it just loads [url] and lets the user read it, with a close button and a
|
||||
/// progress bar. Push it with a plain `Navigator.push(MaterialPageRoute(...))`.
|
||||
class WebPageScreen extends StatefulWidget {
|
||||
final String url;
|
||||
final String title;
|
||||
|
||||
const WebPageScreen({
|
||||
super.key,
|
||||
required this.url,
|
||||
required this.title,
|
||||
});
|
||||
|
||||
@override
|
||||
State<WebPageScreen> createState() => _WebPageScreenState();
|
||||
}
|
||||
|
||||
class _WebPageScreenState extends State<WebPageScreen> {
|
||||
late final WebViewController _controller;
|
||||
int _progress = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = WebViewController()
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
..setBackgroundColor(HaloTokens.surface)
|
||||
..setNavigationDelegate(
|
||||
NavigationDelegate(
|
||||
onProgress: (p) {
|
||||
if (!mounted) return;
|
||||
setState(() => _progress = p);
|
||||
},
|
||||
onWebResourceError: (error) {
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
'[WebPageScreen] WebResourceError '
|
||||
'code=${error.errorCode} type=${error.errorType} '
|
||||
'desc=${error.description} url=${error.url}',
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
..loadRequest(Uri.parse(widget.url));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: HaloTokens.surface,
|
||||
appBar: AppBar(
|
||||
backgroundColor: HaloTokens.surface,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close, color: HaloTokens.brandDark),
|
||||
tooltip: 'Tutup',
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
title: Text(
|
||||
widget.title,
|
||||
style: const TextStyle(
|
||||
fontFamily: HaloTokens.fontDisplay,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: HaloTokens.brandDark,
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
bottom: _progress < 100
|
||||
? PreferredSize(
|
||||
preferredSize: const Size.fromHeight(2),
|
||||
child: LinearProgressIndicator(
|
||||
value: _progress / 100.0,
|
||||
minHeight: 2,
|
||||
backgroundColor: HaloTokens.brandSofter,
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(
|
||||
HaloTokens.brand,
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
body: WebViewWidget(controller: _controller),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../core/auth/auth_notifier.dart';
|
||||
import '../../core/theme/halo_tokens.dart';
|
||||
import '../../core/widgets/web_page_screen.dart';
|
||||
import '../home/widgets/halo_tab_bar.dart';
|
||||
|
||||
/// "Kamu" tab — profile screen.
|
||||
@@ -78,7 +79,14 @@ class ProfileScreen extends ConsumerWidget {
|
||||
_MenuItemData(
|
||||
icon: Icons.lock_outline,
|
||||
label: 'kebijakan privasi',
|
||||
onTap: () {},
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const WebPageScreen(
|
||||
url: 'https://mybestieindonesia.com/privacy',
|
||||
title: 'kebijakan privasi',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
@@ -1,23 +1,18 @@
|
||||
// File generated by FlutterFire CLI.
|
||||
// File generated by FlutterFire CLI (regenerated from the registered
|
||||
// dev Firebase apps — project halobestie-clone-dev).
|
||||
// ignore_for_file: type=lint
|
||||
import 'package:firebase_core/firebase_core.dart' show FirebaseOptions;
|
||||
import 'package:flutter/foundation.dart'
|
||||
show defaultTargetPlatform, kIsWeb, TargetPlatform;
|
||||
|
||||
/// Default [FirebaseOptions] for use with your Firebase apps.
|
||||
///
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// import 'firebase_options.dart';
|
||||
/// // ...
|
||||
/// await Firebase.initializeApp(
|
||||
/// options: DefaultFirebaseOptions.currentPlatform,
|
||||
/// );
|
||||
/// ```
|
||||
class DefaultFirebaseOptions {
|
||||
/// [FirebaseOptions] for the DEV environment (project halobestie-clone-dev).
|
||||
class DevFirebaseOptions {
|
||||
static FirebaseOptions get currentPlatform {
|
||||
if (kIsWeb) {
|
||||
return web;
|
||||
throw UnsupportedError(
|
||||
'DefaultFirebaseOptions have not been configured for web - '
|
||||
'you can reconfigure this by running the FlutterFire CLI again.',
|
||||
);
|
||||
}
|
||||
switch (defaultTargetPlatform) {
|
||||
case TargetPlatform.android:
|
||||
@@ -48,7 +43,7 @@ class DefaultFirebaseOptions {
|
||||
|
||||
static const FirebaseOptions android = FirebaseOptions(
|
||||
apiKey: 'AIzaSyDFWlLSWytqwI7LSdUbVrO7J5De9L2LV2U',
|
||||
appId: '1:1068156046511:android:f30784f6b0423131b8185a',
|
||||
appId: '1:1068156046511:android:1f589ed358ccdad0b8185a',
|
||||
messagingSenderId: '1068156046511',
|
||||
projectId: 'halobestie-clone-dev',
|
||||
storageBucket: 'halobestie-clone-dev.firebasestorage.app',
|
||||
@@ -56,21 +51,10 @@ class DefaultFirebaseOptions {
|
||||
|
||||
static const FirebaseOptions ios = FirebaseOptions(
|
||||
apiKey: 'AIzaSyAQnB5hbj0T5tE4JQZQ9Tx6Whp_u15obMI',
|
||||
appId: '1:1068156046511:ios:b781f67a57d6db7bb8185a',
|
||||
appId: '1:1068156046511:ios:bc9098ffc2c2913ab8185a',
|
||||
messagingSenderId: '1068156046511',
|
||||
projectId: 'halobestie-clone-dev',
|
||||
storageBucket: 'halobestie-clone-dev.firebasestorage.app',
|
||||
iosBundleId: 'com.mybestie.mitra',
|
||||
iosBundleId: 'com.asc.hallobestie.dev',
|
||||
);
|
||||
|
||||
static const FirebaseOptions web = FirebaseOptions(
|
||||
apiKey: 'AIzaSyAvDQp6xLOZHSwhaj9Zk3DjcMvQyX0Y7Oc',
|
||||
appId: '1:1068156046511:web:15b173b38aa563ceb8185a',
|
||||
messagingSenderId: '1068156046511',
|
||||
projectId: 'halobestie-clone-dev',
|
||||
authDomain: 'halobestie-clone-dev.firebaseapp.com',
|
||||
storageBucket: 'halobestie-clone-dev.firebasestorage.app',
|
||||
measurementId: 'G-FK3V0LB3TT',
|
||||
);
|
||||
|
||||
}
|
||||
61
client_app/lib/firebase/firebase_options_prod.dart
Normal file
61
client_app/lib/firebase/firebase_options_prod.dart
Normal file
@@ -0,0 +1,61 @@
|
||||
// File generated by FlutterFire CLI (regenerated from the registered
|
||||
// prod Firebase apps — project my-bestie-production).
|
||||
// ignore_for_file: type=lint
|
||||
import 'package:firebase_core/firebase_core.dart' show FirebaseOptions;
|
||||
import 'package:flutter/foundation.dart'
|
||||
show defaultTargetPlatform, kIsWeb, TargetPlatform;
|
||||
|
||||
/// [FirebaseOptions] for the PROD environment (project my-bestie-production).
|
||||
class ProdFirebaseOptions {
|
||||
static FirebaseOptions get currentPlatform {
|
||||
if (kIsWeb) {
|
||||
throw UnsupportedError(
|
||||
'DefaultFirebaseOptions have not been configured for web - '
|
||||
'you can reconfigure this by running the FlutterFire CLI again.',
|
||||
);
|
||||
}
|
||||
switch (defaultTargetPlatform) {
|
||||
case TargetPlatform.android:
|
||||
return android;
|
||||
case TargetPlatform.iOS:
|
||||
return ios;
|
||||
case TargetPlatform.macOS:
|
||||
throw UnsupportedError(
|
||||
'DefaultFirebaseOptions have not been configured for macos - '
|
||||
'you can reconfigure this by running the FlutterFire CLI again.',
|
||||
);
|
||||
case TargetPlatform.windows:
|
||||
throw UnsupportedError(
|
||||
'DefaultFirebaseOptions have not been configured for windows - '
|
||||
'you can reconfigure this by running the FlutterFire CLI again.',
|
||||
);
|
||||
case TargetPlatform.linux:
|
||||
throw UnsupportedError(
|
||||
'DefaultFirebaseOptions have not been configured for linux - '
|
||||
'you can reconfigure this by running the FlutterFire CLI again.',
|
||||
);
|
||||
default:
|
||||
throw UnsupportedError(
|
||||
'DefaultFirebaseOptions are not supported for this platform.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static const FirebaseOptions android = FirebaseOptions(
|
||||
apiKey: 'AIzaSyAxAp8hcXO-P7HwwVsS3vFe0OX5ZkIyyWI',
|
||||
appId: '1:953866659887:android:55dfbf97ac7c26e7183eda',
|
||||
messagingSenderId: '953866659887',
|
||||
projectId: 'my-bestie-production',
|
||||
storageBucket: 'my-bestie-production.firebasestorage.app',
|
||||
);
|
||||
|
||||
static const FirebaseOptions ios = FirebaseOptions(
|
||||
apiKey: 'AIzaSyA8guPSD87eDLeCsH6jVd1n2_SI4_MaGNE',
|
||||
appId: '1:953866659887:ios:159fd11b1d2f3633183eda',
|
||||
messagingSenderId: '953866659887',
|
||||
projectId: 'my-bestie-production',
|
||||
storageBucket: 'my-bestie-production.firebasestorage.app',
|
||||
iosClientId: '953866659887-bsb3c2a6ir10u47q8vcacre2tmnk59jb.apps.googleusercontent.com',
|
||||
iosBundleId: 'com.asc.hallobestie',
|
||||
);
|
||||
}
|
||||
61
client_app/lib/firebase/firebase_options_staging.dart
Normal file
61
client_app/lib/firebase/firebase_options_staging.dart
Normal file
@@ -0,0 +1,61 @@
|
||||
// File generated by FlutterFire CLI (regenerated from the registered
|
||||
// staging Firebase apps — project my-bestie-876ec).
|
||||
// ignore_for_file: type=lint
|
||||
import 'package:firebase_core/firebase_core.dart' show FirebaseOptions;
|
||||
import 'package:flutter/foundation.dart'
|
||||
show defaultTargetPlatform, kIsWeb, TargetPlatform;
|
||||
|
||||
/// [FirebaseOptions] for the STAGING environment (project my-bestie-876ec).
|
||||
class StagingFirebaseOptions {
|
||||
static FirebaseOptions get currentPlatform {
|
||||
if (kIsWeb) {
|
||||
throw UnsupportedError(
|
||||
'DefaultFirebaseOptions have not been configured for web - '
|
||||
'you can reconfigure this by running the FlutterFire CLI again.',
|
||||
);
|
||||
}
|
||||
switch (defaultTargetPlatform) {
|
||||
case TargetPlatform.android:
|
||||
return android;
|
||||
case TargetPlatform.iOS:
|
||||
return ios;
|
||||
case TargetPlatform.macOS:
|
||||
throw UnsupportedError(
|
||||
'DefaultFirebaseOptions have not been configured for macos - '
|
||||
'you can reconfigure this by running the FlutterFire CLI again.',
|
||||
);
|
||||
case TargetPlatform.windows:
|
||||
throw UnsupportedError(
|
||||
'DefaultFirebaseOptions have not been configured for windows - '
|
||||
'you can reconfigure this by running the FlutterFire CLI again.',
|
||||
);
|
||||
case TargetPlatform.linux:
|
||||
throw UnsupportedError(
|
||||
'DefaultFirebaseOptions have not been configured for linux - '
|
||||
'you can reconfigure this by running the FlutterFire CLI again.',
|
||||
);
|
||||
default:
|
||||
throw UnsupportedError(
|
||||
'DefaultFirebaseOptions are not supported for this platform.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static const FirebaseOptions android = FirebaseOptions(
|
||||
apiKey: 'AIzaSyAOPkPJkHXLFzo9ICOHyjee2Vn_EUqt1Pc',
|
||||
appId: '1:650461407929:android:05754df9552e0529504968',
|
||||
messagingSenderId: '650461407929',
|
||||
projectId: 'my-bestie-876ec',
|
||||
storageBucket: 'my-bestie-876ec.firebasestorage.app',
|
||||
);
|
||||
|
||||
static const FirebaseOptions ios = FirebaseOptions(
|
||||
apiKey: 'AIzaSyC_BewS88iaNsc9QdwsPzkV0sf9wUs4i_4',
|
||||
appId: '1:650461407929:ios:4ee79d479b69d688504968',
|
||||
messagingSenderId: '650461407929',
|
||||
projectId: 'my-bestie-876ec',
|
||||
storageBucket: 'my-bestie-876ec.firebasestorage.app',
|
||||
iosClientId: '650461407929-fb4t48nmguaslfvis7ebsea8vndro7ac.apps.googleusercontent.com',
|
||||
iosBundleId: 'com.asc.hallobestie.staging',
|
||||
);
|
||||
}
|
||||
@@ -15,10 +15,23 @@ import 'core/chat/chat_notifier.dart';
|
||||
import 'core/notifications/notification_service.dart';
|
||||
import 'core/pairing/pairing_notifier.dart';
|
||||
import 'core/theme/halo_theme.dart';
|
||||
import 'firebase_options.dart';
|
||||
import 'firebase/firebase_options_dev.dart';
|
||||
import 'router.dart';
|
||||
|
||||
void main() async {
|
||||
/// Shared app bootstrap, parameterised per build flavor.
|
||||
///
|
||||
/// The flavor entrypoints (`main_dev.dart`, `main_staging.dart`,
|
||||
/// `main_prod.dart`) each call this with their environment's
|
||||
/// [FirebaseOptions] and a [flavor] tag. The bare [main] below delegates to
|
||||
/// dev so a plain `flutter run` (no `-t`) still launches the dev environment.
|
||||
///
|
||||
/// `flavor` is currently informational (kept on hand for future flavor-gated
|
||||
/// behaviour / analytics tagging); the API base URL is supplied separately via
|
||||
/// `--dart-define-from-file=env/<flavor>.json` (see BUILD_FLAVORS.md).
|
||||
Future<void> bootstrap({
|
||||
required FirebaseOptions firebaseOptions,
|
||||
required String flavor,
|
||||
}) async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Pre-warm flutter_secure_storage. The first call triggers AndroidX
|
||||
@@ -28,7 +41,7 @@ void main() async {
|
||||
// splash instead of paying it on the user's first interaction.
|
||||
unawaited(TokenStorage().readRefreshToken());
|
||||
|
||||
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
|
||||
await Firebase.initializeApp(options: firebaseOptions);
|
||||
|
||||
// Enable GA4 collection. Fire-and-forget so it never adds to cold-start
|
||||
// latency; the SDK queues events until collection is on.
|
||||
@@ -42,6 +55,16 @@ void main() async {
|
||||
runApp(const ProviderScope(child: App()));
|
||||
}
|
||||
|
||||
void main() async {
|
||||
// Bare `flutter run` (no `-t lib/main_<flavor>.dart`) defaults to dev so
|
||||
// local development works out of the box. Build-flavor APKs use the
|
||||
// flavor-specific entrypoints instead.
|
||||
await bootstrap(
|
||||
firebaseOptions: DevFirebaseOptions.currentPlatform,
|
||||
flavor: 'dev',
|
||||
);
|
||||
}
|
||||
|
||||
class App extends ConsumerStatefulWidget {
|
||||
const App({super.key});
|
||||
|
||||
|
||||
14
client_app/lib/main_dev.dart
Normal file
14
client_app/lib/main_dev.dart
Normal file
@@ -0,0 +1,14 @@
|
||||
import 'firebase/firebase_options_dev.dart';
|
||||
import 'main.dart';
|
||||
|
||||
/// DEV flavor entrypoint.
|
||||
///
|
||||
/// Run/build with the matching flavor + env file:
|
||||
/// flutter run --flavor dev -t lib/main_dev.dart \
|
||||
/// --dart-define-from-file=env/dev.json
|
||||
void main() {
|
||||
bootstrap(
|
||||
firebaseOptions: DevFirebaseOptions.currentPlatform,
|
||||
flavor: 'dev',
|
||||
);
|
||||
}
|
||||
14
client_app/lib/main_prod.dart
Normal file
14
client_app/lib/main_prod.dart
Normal file
@@ -0,0 +1,14 @@
|
||||
import 'firebase/firebase_options_prod.dart';
|
||||
import 'main.dart';
|
||||
|
||||
/// PROD flavor entrypoint.
|
||||
///
|
||||
/// Run/build with the matching flavor + env file:
|
||||
/// flutter build apk --flavor prod -t lib/main_prod.dart \
|
||||
/// --dart-define-from-file=env/prod.json
|
||||
void main() {
|
||||
bootstrap(
|
||||
firebaseOptions: ProdFirebaseOptions.currentPlatform,
|
||||
flavor: 'prod',
|
||||
);
|
||||
}
|
||||
14
client_app/lib/main_staging.dart
Normal file
14
client_app/lib/main_staging.dart
Normal file
@@ -0,0 +1,14 @@
|
||||
import 'firebase/firebase_options_staging.dart';
|
||||
import 'main.dart';
|
||||
|
||||
/// STAGING flavor entrypoint.
|
||||
///
|
||||
/// Run/build with the matching flavor + env file:
|
||||
/// flutter run --flavor staging -t lib/main_staging.dart \
|
||||
/// --dart-define-from-file=env/staging.json
|
||||
void main() {
|
||||
bootstrap(
|
||||
firebaseOptions: StagingFirebaseOptions.currentPlatform,
|
||||
flavor: 'staging',
|
||||
);
|
||||
}
|
||||
121
mitra_app/BUILD_FLAVORS.md
Normal file
121
mitra_app/BUILD_FLAVORS.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# Build Flavors — mitra_app (Android)
|
||||
|
||||
The mitra_app has three Android build flavors: **dev**, **staging**, **prod**.
|
||||
Each has its own `applicationId`, backend URL, app display name, Dart entrypoint,
|
||||
Firebase Dart options, and `google-services.json` source set — so all three can
|
||||
be installed side-by-side on one device.
|
||||
|
||||
> Scope note: this is **Android + Dart + env-files** only. iOS Xcode schemes are
|
||||
> a separate follow-up and are NOT set up yet.
|
||||
|
||||
## Flavor matrix
|
||||
|
||||
| Flavor | applicationId | API_BASE_URL | App name | Entrypoint | env file |
|
||||
|---------|-----------------------------|-------------------------------------------|---------------------------|-----------------------|-------------------|
|
||||
| dev | `com.mybestie.mitra.dev` | `http://192.168.88.247:3000` | Mitra HaloBestie Dev | `lib/main_dev.dart` | `env/dev.json` |
|
||||
| staging | `com.mybestie.mitra.staging`| `https://staging-api.halobestie.com` ⚠️ | Mitra HaloBestie Staging | `lib/main_staging.dart`| `env/staging.json`|
|
||||
| prod | `com.mybestie.mitra` | `https://api.halobestie.com` | Mitra HaloBestie | `lib/main_prod.dart` | `env/prod.json` |
|
||||
|
||||
⚠️ The staging `API_BASE_URL` is a **placeholder** — confirm the real staging
|
||||
host and update `env/staging.json` + `lib/firebase/firebase_options_staging.dart`.
|
||||
|
||||
The `applicationId` suffix is applied in `android/app/build.gradle.kts`
|
||||
(`applicationIdSuffix = ".dev"` / `".staging"`; prod has none). The app name is
|
||||
emitted per flavor via `resValue("string", "app_name", "...")` and read by
|
||||
`android/app/src/main/AndroidManifest.xml` through `android:label="@string/app_name"`.
|
||||
|
||||
## Build / run commands
|
||||
|
||||
Every command MUST pass `--flavor`, a matching `-t` entrypoint, and
|
||||
`--dart-define-from-file` for the env. Examples:
|
||||
|
||||
### Run (debug, on a device/emulator)
|
||||
```bash
|
||||
flutter run --flavor dev -t lib/main_dev.dart --dart-define-from-file=env/dev.json
|
||||
flutter run --flavor staging -t lib/main_staging.dart --dart-define-from-file=env/staging.json
|
||||
flutter run --flavor prod -t lib/main_prod.dart --dart-define-from-file=env/prod.json
|
||||
```
|
||||
|
||||
### Build APK
|
||||
```bash
|
||||
flutter build apk --flavor dev -t lib/main_dev.dart --dart-define-from-file=env/dev.json
|
||||
flutter build apk --flavor staging -t lib/main_staging.dart --dart-define-from-file=env/staging.json
|
||||
flutter build apk --flavor prod -t lib/main_prod.dart --dart-define-from-file=env/prod.json
|
||||
```
|
||||
|
||||
### Build App Bundle (Play Store)
|
||||
```bash
|
||||
flutter build appbundle --flavor prod -t lib/main_prod.dart --dart-define-from-file=env/prod.json
|
||||
```
|
||||
|
||||
A bare `flutter run` (no `-t`) still works — `lib/main.dart` delegates to the
|
||||
dev bootstrap — but it builds with no flavor selected on Android, so prefer the
|
||||
explicit commands above.
|
||||
|
||||
## ⚠️ CRITICAL warnings
|
||||
|
||||
1. **`--flavor` is now mandatory for builds.** Once product flavors exist, a
|
||||
bare `flutter build apk` (without `--flavor`) FAILS with a Gradle error
|
||||
(no default flavor). Every build/run command must specify `--flavor` and the
|
||||
matching `-t lib/main_<flavor>.dart` entrypoint.
|
||||
|
||||
2. **The dev applicationId changed to `com.mybestie.mitra.dev`.** Any tooling
|
||||
that references the old package id must be updated when running the dev
|
||||
flavor:
|
||||
- `adb` commands: `adb shell pm clear com.mybestie.mitra.dev`,
|
||||
`adb shell am start ... com.mybestie.mitra.dev/...`, etc.
|
||||
- Maestro flows: `appId: com.mybestie.mitra.dev`.
|
||||
- Any deeplink / FCM tooling keyed on the package name.
|
||||
Prod keeps `com.mybestie.mitra`; staging is `com.mybestie.mitra.staging`.
|
||||
|
||||
## Firebase config — STATUS: configured ✅ (2026-06-04)
|
||||
|
||||
Firebase init is **Dart-side** (`Firebase.initializeApp(options:)` in
|
||||
`lib/bootstrap.dart`), driven by the per-flavor
|
||||
`lib/firebase/firebase_options_<flavor>.dart`. The mitra app is a **brand-new
|
||||
app** on both platforms (no legacy App Store identity), so the iOS bundle base
|
||||
is `com.mybestie.mitra` — unlike the customer app, which inherits
|
||||
`com.asc.hallobestie`.
|
||||
|
||||
All apps registered + config in place across 3 projects (one per env):
|
||||
|
||||
| Env | Firebase project | Android applicationId | iOS bundle ID |
|
||||
|---------|------------------------|-------------------------------|------------------------------|
|
||||
| dev | `halobestie-clone-dev` | `com.mybestie.mitra.dev` | `com.mybestie.mitra.dev` |
|
||||
| staging | `my-bestie-876ec` | `com.mybestie.mitra.staging` | `com.mybestie.mitra.staging` |
|
||||
| prod | `my-bestie-production` | `com.mybestie.mitra` | `com.mybestie.mitra` |
|
||||
|
||||
In place and verified:
|
||||
- `android/app/src/<flavor>/google-services.json` — all 3, client matches the flavor applicationId.
|
||||
- `ios/config/<flavor>/GoogleService-Info.plist` — all 3, bundle IDs verified.
|
||||
- `lib/firebase/firebase_options_{dev,staging,prod}.dart` — real android + iOS values, no placeholders.
|
||||
|
||||
### Regenerating after any ID / key change
|
||||
```bash
|
||||
flutterfire configure --project=halobestie-clone-dev --out=lib/firebase/firebase_options_dev.dart
|
||||
flutterfire configure --project=my-bestie-876ec --out=lib/firebase/firebase_options_staging.dart
|
||||
flutterfire configure --project=my-bestie-production --out=lib/firebase/firebase_options_prod.dart
|
||||
```
|
||||
|
||||
### Still TODO — iOS only (Mac/Xcode)
|
||||
- [ ] iOS Xcode schemes + build-phase copy script to select the right
|
||||
`GoogleService-Info.plist` per flavor (until then iOS bundles only
|
||||
`ios/Runner/GoogleService-Info.plist`). See `ios/config/README.md`.
|
||||
|
||||
> The Google Services Gradle plugin is **not** applied in this app — Android
|
||||
> Firebase init is Dart-side. The `src/<flavor>/google-services.json` files are
|
||||
> laid out for if/when that plugin is added.
|
||||
|
||||
## File map
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `android/app/build.gradle.kts` | `flavorDimensions "env"` + `productFlavors` (id suffix + app_name) |
|
||||
| `android/app/src/main/AndroidManifest.xml` | `android:label="@string/app_name"` |
|
||||
| `android/app/src/dev/google-services.json` | dev Firebase config |
|
||||
| `android/app/src/{staging,prod}/google-services.json.README` | placeholders — drop real json here |
|
||||
| `lib/bootstrap.dart` | shared `bootstrap()` + `App` widget |
|
||||
| `lib/main.dart` | bare entrypoint → delegates to dev |
|
||||
| `lib/main_{dev,staging,prod}.dart` | per-flavor entrypoints |
|
||||
| `lib/firebase/firebase_options_{dev,staging,prod}.dart` | per-flavor Dart Firebase options |
|
||||
| `env/{dev,staging,prod}.json` | dart-define values (`API_BASE_URL`, `FLAVOR`) |
|
||||
@@ -21,7 +21,8 @@ android {
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
// Base Application ID. Per-flavor suffixes are applied below in
|
||||
// productFlavors (dev → .dev, staging → .staging, prod → no suffix).
|
||||
applicationId = "com.mybestie.mitra"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
@@ -31,6 +32,30 @@ android {
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
// Build flavors: dev / staging / prod. Each gets its own applicationId
|
||||
// (so all three can be installed side-by-side) and its own app_name string
|
||||
// resource (consumed by AndroidManifest's android:label="@string/app_name").
|
||||
// A bare `flutter build`/`flutter run` WITHOUT --flavor now fails — every
|
||||
// command must pass --flavor and the matching -t entrypoint.
|
||||
flavorDimensions += "env"
|
||||
productFlavors {
|
||||
create("dev") {
|
||||
dimension = "env"
|
||||
applicationIdSuffix = ".dev"
|
||||
resValue("string", "app_name", "Mitra HaloBestie Dev")
|
||||
}
|
||||
create("staging") {
|
||||
dimension = "env"
|
||||
applicationIdSuffix = ".staging"
|
||||
resValue("string", "app_name", "Mitra HaloBestie Staging")
|
||||
}
|
||||
create("prod") {
|
||||
dimension = "env"
|
||||
// No applicationIdSuffix — prod keeps the base com.mybestie.mitra.
|
||||
resValue("string", "app_name", "Mitra HaloBestie")
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
|
||||
@@ -61,6 +61,63 @@
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:1068156046511:android:1f589ed358ccdad0b8185a",
|
||||
"android_client_info": {
|
||||
"package_name": "com.mybestie.dev"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyDFWlLSWytqwI7LSdUbVrO7J5De9L2LV2U"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:1068156046511:android:8a873c8b7e64410ab8185a",
|
||||
"android_client_info": {
|
||||
"package_name": "com.mybestie.mitra"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyDFWlLSWytqwI7LSdUbVrO7J5De9L2LV2U"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:1068156046511:android:f527c763dea3dc36b8185a",
|
||||
"android_client_info": {
|
||||
"package_name": "com.mybestie.mitra.dev"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyDFWlLSWytqwI7LSdUbVrO7J5De9L2LV2U"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
@@ -1,6 +1,6 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application
|
||||
android:label="Mitra HaloBestie"
|
||||
android:label="@string/app_name"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:usesCleartextTraffic="true">
|
||||
|
||||
106
mitra_app/android/app/src/prod/google-services.json
Executable file
106
mitra_app/android/app/src/prod/google-services.json
Executable file
@@ -0,0 +1,106 @@
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "953866659887",
|
||||
"project_id": "my-bestie-production",
|
||||
"storage_bucket": "my-bestie-production.firebasestorage.app"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:953866659887:android:55dfbf97ac7c26e7183eda",
|
||||
"android_client_info": {
|
||||
"package_name": "com.mybestie"
|
||||
}
|
||||
},
|
||||
"oauth_client": [
|
||||
{
|
||||
"client_id": "953866659887-5a62u0tdce92i0gmfo0gf3dt0dnlre42.apps.googleusercontent.com",
|
||||
"client_type": 1,
|
||||
"android_info": {
|
||||
"package_name": "com.mybestie",
|
||||
"certificate_hash": "3c43db3c9ac7f6d7e2fa03b8dbcaf7e5d12c97f3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_id": "953866659887-kebg3eijcomtv97q6v03fm8i30kj7r9r.apps.googleusercontent.com",
|
||||
"client_type": 1,
|
||||
"android_info": {
|
||||
"package_name": "com.mybestie",
|
||||
"certificate_hash": "7119b6cf7091074759450c899191905a5a4d0369"
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_id": "953866659887-vvur2mnmbu8ljmnmmg01hqsrj0ocssu9.apps.googleusercontent.com",
|
||||
"client_type": 1,
|
||||
"android_info": {
|
||||
"package_name": "com.mybestie",
|
||||
"certificate_hash": "6e23f0164af04ddf200f769c460caac3ee2b91ac"
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_id": "953866659887-9ro36er68qupv00rgokjignnu2hs85v8.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
}
|
||||
],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyAxAp8hcXO-P7HwwVsS3vFe0OX5ZkIyyWI"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": [
|
||||
{
|
||||
"client_id": "953866659887-9ro36er68qupv00rgokjignnu2hs85v8.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
},
|
||||
{
|
||||
"client_id": "953866659887-bsb3c2a6ir10u47q8vcacre2tmnk59jb.apps.googleusercontent.com",
|
||||
"client_type": 2,
|
||||
"ios_info": {
|
||||
"bundle_id": "com.asc.hallobestie"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:953866659887:android:a4b99d675b0b0315183eda",
|
||||
"android_client_info": {
|
||||
"package_name": "com.mybestie.mitra"
|
||||
}
|
||||
},
|
||||
"oauth_client": [
|
||||
{
|
||||
"client_id": "953866659887-9ro36er68qupv00rgokjignnu2hs85v8.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
}
|
||||
],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyAxAp8hcXO-P7HwwVsS3vFe0OX5ZkIyyWI"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": [
|
||||
{
|
||||
"client_id": "953866659887-9ro36er68qupv00rgokjignnu2hs85v8.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
},
|
||||
{
|
||||
"client_id": "953866659887-bsb3c2a6ir10u47q8vcacre2tmnk59jb.apps.googleusercontent.com",
|
||||
"client_type": 2,
|
||||
"ios_info": {
|
||||
"bundle_id": "com.asc.hallobestie"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
29
mitra_app/android/app/src/prod/google-services.json.README
Normal file
29
mitra_app/android/app/src/prod/google-services.json.README
Normal file
@@ -0,0 +1,29 @@
|
||||
PROD flavor — google-services.json goes HERE
|
||||
============================================
|
||||
|
||||
This directory is the `prod` flavor source set. The Google Services Gradle
|
||||
plugin reads `android/app/src/prod/google-services.json` when you build the
|
||||
prod flavor. It is intentionally MISSING right now.
|
||||
|
||||
To make the prod flavor build:
|
||||
|
||||
1. In the Firebase Console (the PRODUCTION project), add an Android app with
|
||||
package name:
|
||||
|
||||
com.mybestie.mitra
|
||||
|
||||
(prod has NO applicationIdSuffix — it keeps the base id.)
|
||||
|
||||
2. Download the generated `google-services.json`.
|
||||
|
||||
3. Drop it in this folder, replacing this README:
|
||||
|
||||
android/app/src/prod/google-services.json
|
||||
|
||||
4. Also run `flutterfire configure` for the production project/package and paste
|
||||
the generated Dart values into:
|
||||
|
||||
lib/firebase/firebase_options_prod.dart
|
||||
|
||||
DO NOT copy the dev google-services.json here and edit it by hand — the
|
||||
mobilesdk_app_id / api_key must come from the real Firebase registration.
|
||||
142
mitra_app/android/app/src/staging/google-services.json
Executable file
142
mitra_app/android/app/src/staging/google-services.json
Executable file
@@ -0,0 +1,142 @@
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "650461407929",
|
||||
"project_id": "my-bestie-876ec",
|
||||
"storage_bucket": "my-bestie-876ec.firebasestorage.app"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:650461407929:android:92d95eb766802bcf504968",
|
||||
"android_client_info": {
|
||||
"package_name": "com.mybestie"
|
||||
}
|
||||
},
|
||||
"oauth_client": [
|
||||
{
|
||||
"client_id": "650461407929-ofuff9cv8d4fj79efj9e2ovt2nj9b83a.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
}
|
||||
],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyAOPkPJkHXLFzo9ICOHyjee2Vn_EUqt1Pc"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": [
|
||||
{
|
||||
"client_id": "650461407929-ofuff9cv8d4fj79efj9e2ovt2nj9b83a.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
},
|
||||
{
|
||||
"client_id": "650461407929-2o7eo5ts2k389pa3l16sq26l3107b52f.apps.googleusercontent.com",
|
||||
"client_type": 2,
|
||||
"ios_info": {
|
||||
"bundle_id": "com.example.halloBestie"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:650461407929:android:7571ae8d5036de5d504968",
|
||||
"android_client_info": {
|
||||
"package_name": "com.mybestie.mitra.staging"
|
||||
}
|
||||
},
|
||||
"oauth_client": [
|
||||
{
|
||||
"client_id": "650461407929-ofuff9cv8d4fj79efj9e2ovt2nj9b83a.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
}
|
||||
],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyAOPkPJkHXLFzo9ICOHyjee2Vn_EUqt1Pc"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": [
|
||||
{
|
||||
"client_id": "650461407929-ofuff9cv8d4fj79efj9e2ovt2nj9b83a.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
},
|
||||
{
|
||||
"client_id": "650461407929-2o7eo5ts2k389pa3l16sq26l3107b52f.apps.googleusercontent.com",
|
||||
"client_type": 2,
|
||||
"ios_info": {
|
||||
"bundle_id": "com.example.halloBestie"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:650461407929:android:05754df9552e0529504968",
|
||||
"android_client_info": {
|
||||
"package_name": "com.mybestie.staging"
|
||||
}
|
||||
},
|
||||
"oauth_client": [
|
||||
{
|
||||
"client_id": "650461407929-3els6l9cegtiphe930vtjr80ffa2p1uv.apps.googleusercontent.com",
|
||||
"client_type": 1,
|
||||
"android_info": {
|
||||
"package_name": "com.mybestie.staging",
|
||||
"certificate_hash": "a46c19a615b3c21b529240dabc8f1cd68bcbd449"
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_id": "650461407929-8lo71sr668gvvj0ntpjjemoqrkr82uid.apps.googleusercontent.com",
|
||||
"client_type": 1,
|
||||
"android_info": {
|
||||
"package_name": "com.mybestie.staging",
|
||||
"certificate_hash": "6e23f0164af04ddf200f769c460caac3ee2b91ac"
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_id": "650461407929-lmj6n5jt818fkdjhjpbhabdd19g82f48.apps.googleusercontent.com",
|
||||
"client_type": 1,
|
||||
"android_info": {
|
||||
"package_name": "com.mybestie.staging",
|
||||
"certificate_hash": "937ecfa181a695a5f1fb5d04df15e490c174caea"
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_id": "650461407929-ofuff9cv8d4fj79efj9e2ovt2nj9b83a.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
}
|
||||
],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyAOPkPJkHXLFzo9ICOHyjee2Vn_EUqt1Pc"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": [
|
||||
{
|
||||
"client_id": "650461407929-ofuff9cv8d4fj79efj9e2ovt2nj9b83a.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
},
|
||||
{
|
||||
"client_id": "650461407929-2o7eo5ts2k389pa3l16sq26l3107b52f.apps.googleusercontent.com",
|
||||
"client_type": 2,
|
||||
"ios_info": {
|
||||
"bundle_id": "com.example.halloBestie"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
STAGING flavor — google-services.json goes HERE
|
||||
================================================
|
||||
|
||||
This directory is the `staging` flavor source set. The Google Services Gradle
|
||||
plugin reads `android/app/src/staging/google-services.json` when you build the
|
||||
staging flavor. It is intentionally MISSING right now.
|
||||
|
||||
To make the staging flavor build:
|
||||
|
||||
1. In the Firebase Console (the staging / nonprod project), add an Android app
|
||||
with package name:
|
||||
|
||||
com.mybestie.mitra.staging
|
||||
|
||||
(note the `.staging` suffix — set by `applicationIdSuffix` in
|
||||
android/app/build.gradle.kts).
|
||||
|
||||
2. Download the generated `google-services.json`.
|
||||
|
||||
3. Drop it in this folder, replacing this README:
|
||||
|
||||
android/app/src/staging/google-services.json
|
||||
|
||||
4. Also run `flutterfire configure` for that project/package and paste the
|
||||
generated Dart values into:
|
||||
|
||||
lib/firebase/firebase_options_staging.dart
|
||||
|
||||
DO NOT copy the dev google-services.json here and edit it by hand — the
|
||||
mobilesdk_app_id / api_key must come from the real Firebase registration.
|
||||
4
mitra_app/env/dev.json
vendored
Normal file
4
mitra_app/env/dev.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"API_BASE_URL": "http://192.168.88.247:3000",
|
||||
"FLAVOR": "dev"
|
||||
}
|
||||
4
mitra_app/env/prod.json
vendored
Normal file
4
mitra_app/env/prod.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"API_BASE_URL": "https://api.halobestie.com",
|
||||
"FLAVOR": "prod"
|
||||
}
|
||||
4
mitra_app/env/staging.json
vendored
Normal file
4
mitra_app/env/staging.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"API_BASE_URL": "https://staging-api.halobestie.com",
|
||||
"FLAVOR": "staging"
|
||||
}
|
||||
34
mitra_app/ios/config/README.md
Normal file
34
mitra_app/ios/config/README.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# iOS per-flavor Firebase config — mitra_app
|
||||
|
||||
iOS has **no** automatic per-flavor resolution (unlike Android's
|
||||
`android/app/src/<flavor>/google-services.json`). Stage the three
|
||||
`GoogleService-Info.plist` files here, one per flavor:
|
||||
|
||||
```
|
||||
ios/config/
|
||||
dev/GoogleService-Info.plist → bundle com.mybestie.mitra.dev (dev project)
|
||||
staging/GoogleService-Info.plist → bundle com.mybestie.mitra.staging (staging/nonprod)
|
||||
prod/GoogleService-Info.plist → bundle com.mybestie.mitra (prod project)
|
||||
```
|
||||
|
||||
> The mitra app is a **brand-new app** on both platforms — no legacy App Store
|
||||
> identity (unlike the customer app, which inherits `com.asc.hallobestie`).
|
||||
|
||||
## Wiring (Xcode — Mac follow-up, not done yet)
|
||||
|
||||
The active plist Xcode bundles is `ios/Runner/GoogleService-Info.plist`. To make
|
||||
it per-flavor, add a **Run Script** build phase to the Runner target, placed
|
||||
**before** "Compile Sources":
|
||||
|
||||
```bash
|
||||
cp "${PROJECT_DIR}/config/${FLAVOR}/GoogleService-Info.plist" \
|
||||
"${PROJECT_DIR}/Runner/GoogleService-Info.plist"
|
||||
```
|
||||
|
||||
`${FLAVOR}` is a per-scheme/configuration build setting you define when creating
|
||||
the dev/staging/prod schemes (e.g. `FLAVOR = dev` in the dev configuration).
|
||||
|
||||
### Single-env shortcut
|
||||
If you only need one env working (e.g. dev) before the full scheme setup, just
|
||||
drop that env's plist directly at `ios/Runner/GoogleService-Info.plist` — no
|
||||
script needed for a single environment.
|
||||
30
mitra_app/ios/config/dev/GoogleService-Info.plist
Executable file
30
mitra_app/ios/config/dev/GoogleService-Info.plist
Executable file
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>API_KEY</key>
|
||||
<string>AIzaSyAQnB5hbj0T5tE4JQZQ9Tx6Whp_u15obMI</string>
|
||||
<key>GCM_SENDER_ID</key>
|
||||
<string>1068156046511</string>
|
||||
<key>PLIST_VERSION</key>
|
||||
<string>1</string>
|
||||
<key>BUNDLE_ID</key>
|
||||
<string>com.mybestie.mitra.dev</string>
|
||||
<key>PROJECT_ID</key>
|
||||
<string>halobestie-clone-dev</string>
|
||||
<key>STORAGE_BUCKET</key>
|
||||
<string>halobestie-clone-dev.firebasestorage.app</string>
|
||||
<key>IS_ADS_ENABLED</key>
|
||||
<false></false>
|
||||
<key>IS_ANALYTICS_ENABLED</key>
|
||||
<false></false>
|
||||
<key>IS_APPINVITE_ENABLED</key>
|
||||
<true></true>
|
||||
<key>IS_GCM_ENABLED</key>
|
||||
<true></true>
|
||||
<key>IS_SIGNIN_ENABLED</key>
|
||||
<true></true>
|
||||
<key>GOOGLE_APP_ID</key>
|
||||
<string>1:1068156046511:ios:907b28451e22981db8185a</string>
|
||||
</dict>
|
||||
</plist>
|
||||
14
mitra_app/ios/config/dev/GoogleService-Info.plist.README
Normal file
14
mitra_app/ios/config/dev/GoogleService-Info.plist.README
Normal file
@@ -0,0 +1,14 @@
|
||||
Place the DEV GoogleService-Info.plist here, named exactly:
|
||||
|
||||
GoogleService-Info.plist
|
||||
|
||||
Register this iOS app first:
|
||||
• Firebase project: halobestie-clone-dev (the DEV project)
|
||||
• iOS bundle ID: com.mybestie.mitra.dev
|
||||
Then download its GoogleService-Info.plist into this folder (delete this .README).
|
||||
|
||||
Also paste its appId/apiKey into lib/firebase/firebase_options_dev.dart.
|
||||
|
||||
How it gets used: a build-phase "Run Script" (added in Xcode — Mac follow-up)
|
||||
copies the per-flavor file into ios/Runner/GoogleService-Info.plist at build
|
||||
time. See ../README.md and BUILD_FLAVORS.md.
|
||||
36
mitra_app/ios/config/prod/GoogleService-Info.plist
Executable file
36
mitra_app/ios/config/prod/GoogleService-Info.plist
Executable file
@@ -0,0 +1,36 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CLIENT_ID</key>
|
||||
<string>953866659887-i0atpahlpqt6r9id17hjeirt5j1uqu8k.apps.googleusercontent.com</string>
|
||||
<key>REVERSED_CLIENT_ID</key>
|
||||
<string>com.googleusercontent.apps.953866659887-i0atpahlpqt6r9id17hjeirt5j1uqu8k</string>
|
||||
<key>ANDROID_CLIENT_ID</key>
|
||||
<string>953866659887-5a62u0tdce92i0gmfo0gf3dt0dnlre42.apps.googleusercontent.com</string>
|
||||
<key>API_KEY</key>
|
||||
<string>AIzaSyA8guPSD87eDLeCsH6jVd1n2_SI4_MaGNE</string>
|
||||
<key>GCM_SENDER_ID</key>
|
||||
<string>953866659887</string>
|
||||
<key>PLIST_VERSION</key>
|
||||
<string>1</string>
|
||||
<key>BUNDLE_ID</key>
|
||||
<string>com.mybestie.mitra</string>
|
||||
<key>PROJECT_ID</key>
|
||||
<string>my-bestie-production</string>
|
||||
<key>STORAGE_BUCKET</key>
|
||||
<string>my-bestie-production.firebasestorage.app</string>
|
||||
<key>IS_ADS_ENABLED</key>
|
||||
<false></false>
|
||||
<key>IS_ANALYTICS_ENABLED</key>
|
||||
<false></false>
|
||||
<key>IS_APPINVITE_ENABLED</key>
|
||||
<true></true>
|
||||
<key>IS_GCM_ENABLED</key>
|
||||
<true></true>
|
||||
<key>IS_SIGNIN_ENABLED</key>
|
||||
<true></true>
|
||||
<key>GOOGLE_APP_ID</key>
|
||||
<string>1:953866659887:ios:cd8dd704842f3489183eda</string>
|
||||
</dict>
|
||||
</plist>
|
||||
16
mitra_app/ios/config/prod/GoogleService-Info.plist.README
Normal file
16
mitra_app/ios/config/prod/GoogleService-Info.plist.README
Normal file
@@ -0,0 +1,16 @@
|
||||
Place the PROD GoogleService-Info.plist here, named exactly:
|
||||
|
||||
GoogleService-Info.plist
|
||||
|
||||
Register this iOS app first:
|
||||
• Firebase project: the PRODUCTION project
|
||||
• iOS bundle ID: com.mybestie.mitra (brand-new app — mitra has no legacy
|
||||
App Store identity, unlike the customer app)
|
||||
Then download its GoogleService-Info.plist into this folder (delete this .README).
|
||||
|
||||
Also paste its values into lib/firebase/firebase_options_prod.dart
|
||||
(currently all PLACEHOLDER).
|
||||
|
||||
How it gets used: a build-phase "Run Script" (added in Xcode — Mac follow-up)
|
||||
copies the per-flavor file into ios/Runner/GoogleService-Info.plist at build
|
||||
time. See ../README.md and BUILD_FLAVORS.md.
|
||||
36
mitra_app/ios/config/staging/GoogleService-Info.plist
Executable file
36
mitra_app/ios/config/staging/GoogleService-Info.plist
Executable file
@@ -0,0 +1,36 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CLIENT_ID</key>
|
||||
<string>650461407929-kuc6m53nehsa677geu57f3fmn9ql6lbg.apps.googleusercontent.com</string>
|
||||
<key>REVERSED_CLIENT_ID</key>
|
||||
<string>com.googleusercontent.apps.650461407929-kuc6m53nehsa677geu57f3fmn9ql6lbg</string>
|
||||
<key>ANDROID_CLIENT_ID</key>
|
||||
<string>650461407929-3els6l9cegtiphe930vtjr80ffa2p1uv.apps.googleusercontent.com</string>
|
||||
<key>API_KEY</key>
|
||||
<string>AIzaSyC_BewS88iaNsc9QdwsPzkV0sf9wUs4i_4</string>
|
||||
<key>GCM_SENDER_ID</key>
|
||||
<string>650461407929</string>
|
||||
<key>PLIST_VERSION</key>
|
||||
<string>1</string>
|
||||
<key>BUNDLE_ID</key>
|
||||
<string>com.mybestie.mitra.staging</string>
|
||||
<key>PROJECT_ID</key>
|
||||
<string>my-bestie-876ec</string>
|
||||
<key>STORAGE_BUCKET</key>
|
||||
<string>my-bestie-876ec.firebasestorage.app</string>
|
||||
<key>IS_ADS_ENABLED</key>
|
||||
<false></false>
|
||||
<key>IS_ANALYTICS_ENABLED</key>
|
||||
<false></false>
|
||||
<key>IS_APPINVITE_ENABLED</key>
|
||||
<true></true>
|
||||
<key>IS_GCM_ENABLED</key>
|
||||
<true></true>
|
||||
<key>IS_SIGNIN_ENABLED</key>
|
||||
<true></true>
|
||||
<key>GOOGLE_APP_ID</key>
|
||||
<string>1:650461407929:ios:b273bda6ad4045ca504968</string>
|
||||
</dict>
|
||||
</plist>
|
||||
15
mitra_app/ios/config/staging/GoogleService-Info.plist.README
Normal file
15
mitra_app/ios/config/staging/GoogleService-Info.plist.README
Normal file
@@ -0,0 +1,15 @@
|
||||
Place the STAGING GoogleService-Info.plist here, named exactly:
|
||||
|
||||
GoogleService-Info.plist
|
||||
|
||||
Register this iOS app first:
|
||||
• Firebase project: the STAGING / nonprod project
|
||||
• iOS bundle ID: com.mybestie.mitra.staging
|
||||
Then download its GoogleService-Info.plist into this folder (delete this .README).
|
||||
|
||||
Also paste its values into lib/firebase/firebase_options_staging.dart
|
||||
(currently all PLACEHOLDER).
|
||||
|
||||
How it gets used: a build-phase "Run Script" (added in Xcode — Mac follow-up)
|
||||
copies the per-flavor file into ios/Runner/GoogleService-Info.plist at build
|
||||
time. See ../README.md and BUILD_FLAVORS.md.
|
||||
125
mitra_app/lib/bootstrap.dart
Normal file
125
mitra_app/lib/bootstrap.dart
Normal file
@@ -0,0 +1,125 @@
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'core/api/api_client_provider.dart';
|
||||
import 'core/auth/auth_notifier.dart';
|
||||
import 'core/chat/mitra_chat_notifier.dart';
|
||||
import 'core/status/status_notifier.dart';
|
||||
import 'core/chat/chat_request_notifier.dart';
|
||||
import 'core/chat/widgets/chat_request_overlay.dart';
|
||||
import 'core/notifications/notification_service.dart';
|
||||
import 'core/theme/halo_theme.dart';
|
||||
import 'router.dart';
|
||||
|
||||
/// Shared app bootstrap used by every flavor entrypoint
|
||||
/// (main_dev / main_staging / main_prod). Each entrypoint passes the
|
||||
/// flavor's own [FirebaseOptions] and a [flavor] tag.
|
||||
Future<void> bootstrap({
|
||||
required FirebaseOptions firebaseOptions,
|
||||
required String flavor,
|
||||
}) async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await Firebase.initializeApp(options: firebaseOptions);
|
||||
|
||||
final messaging = FirebaseMessaging.instance;
|
||||
await messaging.requestPermission();
|
||||
|
||||
runApp(const ProviderScope(child: App()));
|
||||
}
|
||||
|
||||
class App extends ConsumerStatefulWidget {
|
||||
const App({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<App> createState() => _AppState();
|
||||
}
|
||||
|
||||
class _AppState extends ConsumerState<App> with WidgetsBindingObserver {
|
||||
bool _fcmRegistered = false;
|
||||
// Session the chat WS was on at the moment we backgrounded. Restored on
|
||||
// resume so a backgrounded mitra reconnects to the same chat once they
|
||||
// foreground the app. Mirrors the customer-app fix (main.dart on the
|
||||
// client side) — backend's sendMessage checks recipient WS readyState
|
||||
// before falling back to FCM, so leaving the WS open while paused makes
|
||||
// FCM never fire and the mitra misses customer messages in background.
|
||||
String? _pausedChatSessionId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.paused || state == AppLifecycleState.detached) {
|
||||
ref.read(onlineStatusProvider.notifier).onAppPaused();
|
||||
// Close the chat WS so backend `sendMessage` falls back to FCM when
|
||||
// the customer sends a message. Stash the active session_id so we
|
||||
// can rejoin it on resume.
|
||||
final chatNotifier = ref.read(mitraChatProvider.notifier);
|
||||
final sid = chatNotifier.connectedSessionId;
|
||||
if (sid != null) {
|
||||
_pausedChatSessionId = sid;
|
||||
chatNotifier.disconnect();
|
||||
}
|
||||
} else if (state == AppLifecycleState.resumed) {
|
||||
ref.read(onlineStatusProvider.notifier).onAppResumed();
|
||||
// Reconnect to the chat we backgrounded out of, if any.
|
||||
final saved = _pausedChatSessionId;
|
||||
_pausedChatSessionId = null;
|
||||
if (saved != null) {
|
||||
// ignore: discarded_futures
|
||||
ref.read(mitraChatProvider.notifier).connect(saved);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _registerFcmToken() {
|
||||
if (_fcmRegistered) return;
|
||||
_fcmRegistered = true;
|
||||
Future(() async {
|
||||
try {
|
||||
final token = await FirebaseMessaging.instance.getToken();
|
||||
if (token != null) {
|
||||
await ref.read(apiClientProvider).post('/api/shared/device-token', data: {'token': token});
|
||||
}
|
||||
} catch (_) {
|
||||
_fcmRegistered = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Listen for auth changes to load status and register FCM
|
||||
ref.listen(mitraAuthProvider, (prev, next) {
|
||||
final data = next.valueOrNull;
|
||||
if (data is MitraAuthAuthenticatedData) {
|
||||
ref.read(onlineStatusProvider.notifier).load();
|
||||
_registerFcmToken();
|
||||
}
|
||||
});
|
||||
|
||||
final router = ref.watch(routerProvider);
|
||||
NotificationService.initialize(router);
|
||||
NotificationService.onChatRequestTapped = (sessionId) {
|
||||
ref.read(chatRequestProvider.notifier).setIncomingFromNotification(sessionId);
|
||||
};
|
||||
|
||||
return ChatRequestOverlay(
|
||||
child: MaterialApp.router(
|
||||
title: 'Halo Bestie Mitra',
|
||||
theme: haloThemeData(),
|
||||
routerConfig: router,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,18 @@
|
||||
// File generated by FlutterFire CLI.
|
||||
// File generated by FlutterFire CLI (regenerated from the registered
|
||||
// dev Firebase apps — project halobestie-clone-dev).
|
||||
// ignore_for_file: type=lint
|
||||
import 'package:firebase_core/firebase_core.dart' show FirebaseOptions;
|
||||
import 'package:flutter/foundation.dart'
|
||||
show defaultTargetPlatform, kIsWeb, TargetPlatform;
|
||||
|
||||
/// Default [FirebaseOptions] for use with your Firebase apps.
|
||||
///
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// import 'firebase_options.dart';
|
||||
/// // ...
|
||||
/// await Firebase.initializeApp(
|
||||
/// options: DefaultFirebaseOptions.currentPlatform,
|
||||
/// );
|
||||
/// ```
|
||||
/// [FirebaseOptions] for the DEV environment (project halobestie-clone-dev).
|
||||
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.',
|
||||
);
|
||||
throw UnsupportedError(
|
||||
'DefaultFirebaseOptions have not been configured for web - '
|
||||
'you can reconfigure this by running the FlutterFire CLI again.',
|
||||
);
|
||||
}
|
||||
switch (defaultTargetPlatform) {
|
||||
case TargetPlatform.android:
|
||||
@@ -51,7 +43,7 @@ class DefaultFirebaseOptions {
|
||||
|
||||
static const FirebaseOptions android = FirebaseOptions(
|
||||
apiKey: 'AIzaSyDFWlLSWytqwI7LSdUbVrO7J5De9L2LV2U',
|
||||
appId: '1:1068156046511:android:4f8fe9a3c7c14c57b8185a',
|
||||
appId: '1:1068156046511:android:f527c763dea3dc36b8185a',
|
||||
messagingSenderId: '1068156046511',
|
||||
projectId: 'halobestie-clone-dev',
|
||||
storageBucket: 'halobestie-clone-dev.firebasestorage.app',
|
||||
@@ -59,10 +51,10 @@ class DefaultFirebaseOptions {
|
||||
|
||||
static const FirebaseOptions ios = FirebaseOptions(
|
||||
apiKey: 'AIzaSyAQnB5hbj0T5tE4JQZQ9Tx6Whp_u15obMI',
|
||||
appId: '1:1068156046511:ios:c7786cedb9101d34b8185a',
|
||||
appId: '1:1068156046511:ios:907b28451e22981db8185a',
|
||||
messagingSenderId: '1068156046511',
|
||||
projectId: 'halobestie-clone-dev',
|
||||
storageBucket: 'halobestie-clone-dev.firebasestorage.app',
|
||||
iosBundleId: 'com.mybestie',
|
||||
iosBundleId: 'com.mybestie.mitra.dev',
|
||||
);
|
||||
}
|
||||
61
mitra_app/lib/firebase/firebase_options_prod.dart
Normal file
61
mitra_app/lib/firebase/firebase_options_prod.dart
Normal file
@@ -0,0 +1,61 @@
|
||||
// File generated by FlutterFire CLI (regenerated from the registered
|
||||
// prod Firebase apps — project my-bestie-production).
|
||||
// ignore_for_file: type=lint
|
||||
import 'package:firebase_core/firebase_core.dart' show FirebaseOptions;
|
||||
import 'package:flutter/foundation.dart'
|
||||
show defaultTargetPlatform, kIsWeb, TargetPlatform;
|
||||
|
||||
/// [FirebaseOptions] for the PROD environment (project my-bestie-production).
|
||||
class DefaultFirebaseOptions {
|
||||
static FirebaseOptions get currentPlatform {
|
||||
if (kIsWeb) {
|
||||
throw UnsupportedError(
|
||||
'DefaultFirebaseOptions have not been configured for web - '
|
||||
'you can reconfigure this by running the FlutterFire CLI again.',
|
||||
);
|
||||
}
|
||||
switch (defaultTargetPlatform) {
|
||||
case TargetPlatform.android:
|
||||
return android;
|
||||
case TargetPlatform.iOS:
|
||||
return ios;
|
||||
case TargetPlatform.macOS:
|
||||
throw UnsupportedError(
|
||||
'DefaultFirebaseOptions have not been configured for macos - '
|
||||
'you can reconfigure this by running the FlutterFire CLI again.',
|
||||
);
|
||||
case TargetPlatform.windows:
|
||||
throw UnsupportedError(
|
||||
'DefaultFirebaseOptions have not been configured for windows - '
|
||||
'you can reconfigure this by running the FlutterFire CLI again.',
|
||||
);
|
||||
case TargetPlatform.linux:
|
||||
throw UnsupportedError(
|
||||
'DefaultFirebaseOptions have not been configured for linux - '
|
||||
'you can reconfigure this by running the FlutterFire CLI again.',
|
||||
);
|
||||
default:
|
||||
throw UnsupportedError(
|
||||
'DefaultFirebaseOptions are not supported for this platform.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static const FirebaseOptions android = FirebaseOptions(
|
||||
apiKey: 'AIzaSyAxAp8hcXO-P7HwwVsS3vFe0OX5ZkIyyWI',
|
||||
appId: '1:953866659887:android:a4b99d675b0b0315183eda',
|
||||
messagingSenderId: '953866659887',
|
||||
projectId: 'my-bestie-production',
|
||||
storageBucket: 'my-bestie-production.firebasestorage.app',
|
||||
);
|
||||
|
||||
static const FirebaseOptions ios = FirebaseOptions(
|
||||
apiKey: 'AIzaSyA8guPSD87eDLeCsH6jVd1n2_SI4_MaGNE',
|
||||
appId: '1:953866659887:ios:cd8dd704842f3489183eda',
|
||||
messagingSenderId: '953866659887',
|
||||
projectId: 'my-bestie-production',
|
||||
storageBucket: 'my-bestie-production.firebasestorage.app',
|
||||
iosClientId: '953866659887-i0atpahlpqt6r9id17hjeirt5j1uqu8k.apps.googleusercontent.com',
|
||||
iosBundleId: 'com.mybestie.mitra',
|
||||
);
|
||||
}
|
||||
61
mitra_app/lib/firebase/firebase_options_staging.dart
Normal file
61
mitra_app/lib/firebase/firebase_options_staging.dart
Normal file
@@ -0,0 +1,61 @@
|
||||
// File generated by FlutterFire CLI (regenerated from the registered
|
||||
// staging Firebase apps — project my-bestie-876ec).
|
||||
// ignore_for_file: type=lint
|
||||
import 'package:firebase_core/firebase_core.dart' show FirebaseOptions;
|
||||
import 'package:flutter/foundation.dart'
|
||||
show defaultTargetPlatform, kIsWeb, TargetPlatform;
|
||||
|
||||
/// [FirebaseOptions] for the STAGING environment (project my-bestie-876ec).
|
||||
class DefaultFirebaseOptions {
|
||||
static FirebaseOptions get currentPlatform {
|
||||
if (kIsWeb) {
|
||||
throw UnsupportedError(
|
||||
'DefaultFirebaseOptions have not been configured for web - '
|
||||
'you can reconfigure this by running the FlutterFire CLI again.',
|
||||
);
|
||||
}
|
||||
switch (defaultTargetPlatform) {
|
||||
case TargetPlatform.android:
|
||||
return android;
|
||||
case TargetPlatform.iOS:
|
||||
return ios;
|
||||
case TargetPlatform.macOS:
|
||||
throw UnsupportedError(
|
||||
'DefaultFirebaseOptions have not been configured for macos - '
|
||||
'you can reconfigure this by running the FlutterFire CLI again.',
|
||||
);
|
||||
case TargetPlatform.windows:
|
||||
throw UnsupportedError(
|
||||
'DefaultFirebaseOptions have not been configured for windows - '
|
||||
'you can reconfigure this by running the FlutterFire CLI again.',
|
||||
);
|
||||
case TargetPlatform.linux:
|
||||
throw UnsupportedError(
|
||||
'DefaultFirebaseOptions have not been configured for linux - '
|
||||
'you can reconfigure this by running the FlutterFire CLI again.',
|
||||
);
|
||||
default:
|
||||
throw UnsupportedError(
|
||||
'DefaultFirebaseOptions are not supported for this platform.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static const FirebaseOptions android = FirebaseOptions(
|
||||
apiKey: 'AIzaSyAOPkPJkHXLFzo9ICOHyjee2Vn_EUqt1Pc',
|
||||
appId: '1:650461407929:android:7571ae8d5036de5d504968',
|
||||
messagingSenderId: '650461407929',
|
||||
projectId: 'my-bestie-876ec',
|
||||
storageBucket: 'my-bestie-876ec.firebasestorage.app',
|
||||
);
|
||||
|
||||
static const FirebaseOptions ios = FirebaseOptions(
|
||||
apiKey: 'AIzaSyC_BewS88iaNsc9QdwsPzkV0sf9wUs4i_4',
|
||||
appId: '1:650461407929:ios:b273bda6ad4045ca504968',
|
||||
messagingSenderId: '650461407929',
|
||||
projectId: 'my-bestie-876ec',
|
||||
storageBucket: 'my-bestie-876ec.firebasestorage.app',
|
||||
iosClientId: '650461407929-kuc6m53nehsa677geu57f3fmn9ql6lbg.apps.googleusercontent.com',
|
||||
iosBundleId: 'com.mybestie.mitra.staging',
|
||||
);
|
||||
}
|
||||
@@ -1,120 +1,15 @@
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import '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 'firebase_options.dart';
|
||||
import 'router.dart';
|
||||
import 'bootstrap.dart';
|
||||
import 'firebase/firebase_options_dev.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
|
||||
|
||||
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,
|
||||
),
|
||||
/// Default entrypoint — delegates to the DEV flavor so a bare `flutter run`
|
||||
/// (without -t) still works during local development. The `App` widget and the
|
||||
/// shared startup logic now live in [bootstrap].
|
||||
///
|
||||
/// For an explicit flavor, use the dedicated entrypoints instead:
|
||||
/// - 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)
|
||||
/// - 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,
|
||||
flavor: 'dev',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
9
mitra_app/lib/main_dev.dart
Normal file
9
mitra_app/lib/main_dev.dart
Normal file
@@ -0,0 +1,9 @@
|
||||
import 'bootstrap.dart';
|
||||
import 'firebase/firebase_options_dev.dart';
|
||||
|
||||
/// DEV flavor entrypoint.
|
||||
/// Run: flutter run --flavor dev -t lib/main_dev.dart --dart-define-from-file=env/dev.json
|
||||
Future<void> main() => bootstrap(
|
||||
firebaseOptions: DefaultFirebaseOptions.currentPlatform,
|
||||
flavor: 'dev',
|
||||
);
|
||||
9
mitra_app/lib/main_prod.dart
Normal file
9
mitra_app/lib/main_prod.dart
Normal file
@@ -0,0 +1,9 @@
|
||||
import 'bootstrap.dart';
|
||||
import 'firebase/firebase_options_prod.dart';
|
||||
|
||||
/// PROD flavor entrypoint.
|
||||
/// Run: flutter run --flavor prod -t lib/main_prod.dart --dart-define-from-file=env/prod.json
|
||||
Future<void> main() => bootstrap(
|
||||
firebaseOptions: DefaultFirebaseOptions.currentPlatform,
|
||||
flavor: 'prod',
|
||||
);
|
||||
9
mitra_app/lib/main_staging.dart
Normal file
9
mitra_app/lib/main_staging.dart
Normal file
@@ -0,0 +1,9 @@
|
||||
import 'bootstrap.dart';
|
||||
import 'firebase/firebase_options_staging.dart';
|
||||
|
||||
/// STAGING flavor entrypoint.
|
||||
/// Run: flutter run --flavor staging -t lib/main_staging.dart --dart-define-from-file=env/staging.json
|
||||
Future<void> main() => bootstrap(
|
||||
firebaseOptions: DefaultFirebaseOptions.currentPlatform,
|
||||
flavor: 'staging',
|
||||
);
|
||||
@@ -6,7 +6,7 @@ Operational decisions and dependency configuration for staging/production. Keep
|
||||
|
||||
| Component | Service | Tier / Notes |
|
||||
|---|---|---|
|
||||
| Backend (public + internal) | GCP Cloud Run | Horizontal scaling; SIGTERM trapped for graceful drain ([server.js](../backend/src/server.js)) |
|
||||
| Backend (public + internal) | Self-hosted Docker (VPS / Kubernetes / Docker Engine) | NOT Cloud Run. Container from [backend/Dockerfile](../backend/Dockerfile); horizontal scaling via replicas; SIGTERM trapped for graceful drain ([server.js](../backend/src/server.js)) |
|
||||
| Database | GCP Cloud SQL (PostgreSQL) | Source of truth for all durable state |
|
||||
| Pub/sub + cache | Valkey | Self-hosted on VM today; Memorystore Standard (HA) recommended for prod (see [§ Valkey](#valkey)) |
|
||||
| Networking | GCP VPC | Internal listener (port 3001) never exposed; CC reaches it via VPN |
|
||||
@@ -88,7 +88,86 @@ The system is correct on any tier — HA reduces customer-visible latency spikes
|
||||
|
||||
## Cloud Run
|
||||
|
||||
(Placeholder — fill in as we make decisions about region, min/max instances, concurrency, secrets manager wiring.)
|
||||
(Placeholder for prod tuning — fill in as we make decisions about region, min/max instances, concurrency, secrets manager wiring.)
|
||||
|
||||
### Manual staging deploy runbook
|
||||
|
||||
Goal: stand up a staging backend so the Android **staging** flavor (`com.mybestie.staging`) has a real `API_BASE_URL` to talk to. Done manually for now (no CI/CD yet — see open ops).
|
||||
|
||||
> **Deploy target: self-hosted Docker** (VPS / Kubernetes / Docker Engine) — not Cloud Run. The backend ships a multi-stage [backend/Dockerfile](../backend/Dockerfile) (Node 20, non-root runtime, native `bcrypt` compiled in the build stage). Build with `docker build -t halobestie-backend ./backend`.
|
||||
>
|
||||
> **Full operational runbook — install Docker, build/push, migrate, run (Docker + Compose + k8s), and log mapping/rotation — lives in [backend/DEPLOY.md](../backend/DEPLOY.md).** The steps below are the staging-bring-up summary.
|
||||
|
||||
**A1 — Provision the staging database (Cloud SQL Postgres)**
|
||||
1. Create a Cloud SQL Postgres instance (or a separate `halobestie_staging` DB on a shared instance). Pin the **same region** as the Cloud Run service.
|
||||
2. Capture its connection string for `DATABASE_URL` (use the Cloud SQL connector / Unix socket form for Cloud Run, or private IP over the VPC connector).
|
||||
3. Run migrations + seed against it:
|
||||
```bash
|
||||
cd backend
|
||||
DATABASE_URL=postgresql://... npm run db:migrate
|
||||
DATABASE_URL=postgresql://... npm run db:seed
|
||||
```
|
||||
|
||||
**A2 — Provision staging Valkey** — self-hosted Docker on the VM is fine for staging (`--appendonly yes`, see [§ Valkey](#valkey)). Note the `VALKEY_URL`.
|
||||
|
||||
**A3 — Staging Firebase Admin creds** — the app's staging `google-services.json` / `GoogleService-Info.plist` point at Firebase project **`my-bestie-876ec`**. The backend's `FIREBASE_SERVICE_ACCOUNT` **must be a service-account key from that same project**, or FCM push + token verification will silently target the wrong project. Mount it as a secret and set `FIREBASE_SERVICE_ACCOUNT_PATH` (or switch to a Secret Manager mount).
|
||||
|
||||
**A4 — Build the image + run migrations, then start the container.**
|
||||
|
||||
Build (on a build host or in CI), then push to your registry:
|
||||
```bash
|
||||
docker build -t <registry>/halobestie-backend:staging ./backend
|
||||
docker push <registry>/halobestie-backend:staging
|
||||
```
|
||||
|
||||
Run migrations as a **one-off** before (re)starting the service — never auto-migrate on boot (replica race):
|
||||
```bash
|
||||
docker run --rm --env-file backend/.env.staging \
|
||||
<registry>/halobestie-backend:staging node src/db/migrate.js
|
||||
# first deploy only:
|
||||
docker run --rm --env-file backend/.env.staging \
|
||||
<registry>/halobestie-backend:staging node src/db/seed.js
|
||||
```
|
||||
|
||||
Run the service (plain Docker Engine example; k8s = Deployment + Service with the same env/secrets and liveness/readiness probes on `:3000`):
|
||||
```bash
|
||||
docker run -d --name halobestie-staging \
|
||||
--env-file backend/.env.staging \
|
||||
-p 3000:3000 \
|
||||
-v /path/to/firebase-sa.json:/secrets/firebase-sa.json:ro \
|
||||
--restart unless-stopped \
|
||||
<registry>/halobestie-backend:staging
|
||||
```
|
||||
- Publish **only** port 3000. The internal listener (3001) stays bound to `127.0.0.1` inside the container — do not map it.
|
||||
- `FIREBASE_SERVICE_ACCOUNT_PATH` must point at the mounted path (e.g. `/secrets/firebase-sa.json`), not a baked-in file.
|
||||
- Put a TLS-terminating reverse proxy (Nginx / Traefik / Caddy) in front for `https://staging-api.halobestie.com`.
|
||||
|
||||
Staging-specific env values (`backend/.env.staging`; see [backend/.env.example](../backend/.env.example) for the full list):
|
||||
| Var | Staging value |
|
||||
|---|---|
|
||||
| `AUTH_JWT_SECRET` | a fresh secret — **not** the prod one |
|
||||
| `XENDIT_ENABLED` | `false` until you wire test-mode keys + webhook |
|
||||
| `XENDIT_SECRET_KEY` / `XENDIT_WEBHOOK_TOKEN` | Xendit **test** credentials |
|
||||
| `XENDIT_SUCCESS/FAILURE_REDIRECT_URL` | staging backend's `/payment/return/*` URLs |
|
||||
| `FAZPASS_ENABLED` | `false` (test-user OTP bypass path) unless testing real OTP |
|
||||
| `CC_ORIGIN` | staging control-center origin (if deployed) |
|
||||
| `ADMIN_EMAIL` / `ADMIN_PASSWORD` | staging control-center login |
|
||||
|
||||
> **Public listener only.** The internal listener (port 3001, control center) must stay off the public internet — don't expose it from this Cloud Run service. CC for staging, if needed, goes behind the VPC/VPN per the root architecture rules.
|
||||
|
||||
**A5 — Capture the URL.** Point a DNS record (e.g. `staging-api.halobestie.com`) at the host/reverse proxy and terminate TLS there. **This HTTPS URL is the value the app needs** in Phase B.
|
||||
|
||||
### App handoff (Phase B) — once A5 gives a URL
|
||||
1. Put the real URL in [`client_app/env/staging.json`](../client_app/env/staging.json) + [`mitra_app/env/staging.json`](../mitra_app/env/staging.json) (`API_BASE_URL`), and remove the `_TODO` key from the client file.
|
||||
2. Build the staging APK:
|
||||
```bash
|
||||
cd client_app
|
||||
flutter build apk --flavor staging -t lib/main_staging.dart --dart-define-from-file=env/staging.json
|
||||
```
|
||||
Output: `build/app/outputs/flutter-apk/app-staging-release.apk`.
|
||||
3. Distribute via **Firebase App Distribution** (debug-signed APK is accepted — no upload keystore needed for staging) or share the APK directly. `com.mybestie.staging` installs side-by-side with prod.
|
||||
|
||||
> **Release signing is still debug keys** ([client_app/android/app/build.gradle.kts](../client_app/android/app/build.gradle.kts) `release { ... }`). Fine for Firebase App Distribution / direct APK. A real upload keystore is only required if you later publish staging to a Play Store internal-testing track. iOS staging is **not** wired yet (only one `Runner.xcscheme` — no per-flavor schemes/build-configs).
|
||||
|
||||
## Cloud SQL
|
||||
|
||||
|
||||
Reference in New Issue
Block a user