Compare commits

..

16 Commits

Author SHA1 Message Date
bfb072ddfb Docs: textfield-centering pitfall + config-source / FCM channel conventions
- mitra_app/CLAUDE.md: pitfall entry for the InputDecorationTheme
  min-height collision that broke chat-input centering. Walks through
  the working recipe (constraints: BoxConstraints(), Material +
  StadiumBorder + Center wrapper). Points at chat_screen.dart::_InputBar
  in both apps as the source of truth.
- backend/CLAUDE.md: two new convention sections.
  - Config-source: when to use DB-stored (operator-tunable via CC) vs
    env-driven (deploy-fixed). Codifies the pattern shipped today for
    MITRA_HEARTBEAT_CADENCE_SECONDS so Xendit credentials / callback
    tokens follow the same shape tomorrow.
  - FCM channel: single shared `halobestie_chat_v1` channel for both
    apps, target via android.notification.channelId. Bump the channel
    ID when introducing a new sound (Android API 26+ binds sound at
    channel-create time).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:38:50 +08:00
387f0f65de Mitra chat input bar: port the exact pattern from client_app
The missing piece was `constraints: BoxConstraints()` on the
InputDecoration. The app-wide InputDecorationTheme in halo_theme
sets a 48dp min-height for form fields, which the chat input pill
doesn't want. Without explicitly nulling that constraint, the
TextField refuses to collapse below 48dp, so the line-box can't
sit on the parent 44dp container's midline — textAlignVertical
becomes a no-op and the text anchors top.

Switched to the same Material + StadiumBorder + Center wrapper
client_app already uses (chat_screen.dart::_InputBar). Verified
on emulator-5556 driving typed "halo" — text body now sits
visually centered on the pill midline.

Reverts the empirical TextAlignVertical(y: 0.4) shim from 75343f9.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:31:06 +08:00
75343f97b6 Mitra chat input bar: drop maxLines:3 attempt + nudge alignment down
Follow-up to 92da8b2. With `textAlignVertical: center` + `isDense:true`,
the TextField was centering the line-box baseline on the parent
midline — but Latin lowercase glyphs sit at ~75% of line height,
leaving descender space empty below and the optical center of text
visibly above the pill midline.

Fix: `textAlignVertical: TextAlignVertical(y: 0.4)` shifts the
baseline down to align Latin x-height optical centers with the pill
midline. Also added explicit `alignment: Alignment.center` on the
Container so the field's small intrinsic line-box positions on the
midline rather than docking to the top.

Verified on emulator-5556 driving the typed "halo" through the chat
input — text body now sits on the visual midline of the 44dp pill.

The horizontal underline below typed text is Gboard's composing-
region indicator (Android IME behavior), not a TextField underline,
and will go away once the user commits the word with space/send.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:28:03 +08:00
92da8b2013 Mitra chat input bar: white pill bg + vertical-center hint
Previous commit applied the pill/center fixes to the wrong textbox
(goodbye composer). Reverted that and applied to the actual chat
message input bar.

- Container bg: HaloTokens.bg → HaloTokens.surface. The pill now sits
  on white against the cream page (HaloTokens.bg) so the outline reads
  as a proper pill, not a faint shadow. Border color unchanged
  (HaloTokens.border, the previous "shadow" tone).
- TextField wrapped in Center widget. textAlignVertical:center +
  isCollapsed:true alone don't center the field against the parent
  44dp container height — they only center within the field's own
  intrinsic line-box, which then docks top of the parent. Center
  delegates vertical placement to the container's stack, so the hint
  lands on the vertical midline. textAlignVertical removed (Center
  now owns alignment).

Goodbye composer (Pesan Penutup) restored to its prior styling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:11:20 +08:00
82c9b1eee8 Mitra chat: real customer name + ticking timer + goodbye pill
Four small fixes on the mitra chat screen, all surfacing through the
chat connected-state.

1. AppBar customer name. The hardcoded "Customer" only ever came from
   the FCM-tap navigation fallback (notification_service:
   `extra: {'customerName': 'Customer'}`); the popup-overlay path passes
   the real name but FCM had no way to know it. /chat/:sessionId/info
   already returns `customer_display_name` — propagate it into
   MitraChatConnectedData and read in the AppBar via .select. Falls
   back to the route arg for the brief connecting window.

2. SISA WAKTU stuck at --:--. The pill watches a remaining-seconds
   provider that's only updated by backend WS frames. Backend only
   fires session_timer at 3-min + 1-min warnings + expiry, so the pill
   sat at --:-- for the first ~7 minutes of a 10-min chat. Added a
   local 1s ticker in the notifier that drives the provider against
   expires_at (also pulled from /info). WS warning frames still
   overwrite normally on top.

3. Pesan Penutup textbox. Replaced the rounded-rect OutlineInputBorder
   field with a fixed-height Container pill whose border matches the
   previous "shadow" tone (HaloTokens.border). Pill borderRadius is
   the full 100 (was 12).

4. Goodbye textbox text was top-aligned because maxLines: 3 +
   OutlineInputBorder left vertical alignment to InputDecoration's
   built-in padding. Switched to maxLines: 1 + textAlignVertical:
   center + isCollapsed: true inside the fixed-height container —
   text now sits on the vertical center.

Bonus: the goodbye subhead "Tuliskan pesan terakhirmu untuk Customer"
also picked up the real name ("…untuk Andi Pratama").

Verified end-to-end on emulator-5556 (TestMitra-1501 + customer
"Andi Pratama"): AppBar shows Andi Pratama, SISA WAKTU ticks (04:57 →
04:35 across screenshots), goodbye pill renders with centered hint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 21:09:00 +08:00
a8c20d929e Mitra ping: decouple stale-after from app cadence
Splits the single mitra_ping_interval_seconds config (which conflated
"how often the app pings" with "how long until offline" through a
hidden ×3 multiplier) into two orthogonal knobs:

- mitra_stale_after_seconds (CC-tunable, app_config DB row): the
  operator-facing offline threshold. What you set is what you get —
  no multiplier. Default 45s (preserves today's effective grace at
  the legacy 15s ping default).
- MITRA_HEARTBEAT_CADENCE_SECONDS (env var, default 30s): how often
  the mitra app sends a heartbeat. Backend-fixed per deployment;
  surfaced to the mitra app via /api/mitra/status.

Backend:
- config.service: getMitraPingConfig returns the new tuple
  {require_ping, stale_after_seconds, heartbeat_cadence_seconds}.
  Env parser handles blank/non-numeric → 30 fallback.
- mitra-status.service::autoOfflineStaleMitras drops the *3 and uses
  stale_after_seconds directly.
- mitra-status.service::getStatus returns heartbeat_cadence_seconds
  instead of ping_interval_seconds.
- /internal/config/mitra-ping PATCH validates
  stale_after_seconds >= cadence, returns 422 with a clear message
  ("stale_after_seconds must be a number >= heartbeat cadence (30s)").
- migrate.js: adds mitra_stale_after_seconds default 45. The old
  mitra_ping_interval_seconds key is left in place (vestigial) —
  no live code reads it; safe to drop after one release.

Mitra app:
- status_notifier reads heartbeat_cadence_seconds, uses it directly
  as the Timer.periodic interval. Defaults to 30s if missing (older
  backend safety).

Control center:
- SettingsPage: renames "Interval Ping" → "Ambang offline", input
  min={heartbeat_cadence_seconds}, shows the cadence as a read-only
  value with explanation that it's env-controlled.

Verified end-to-end on dev backend:
- GET /api/mitra/status returns {…, heartbeat_cadence_seconds: 30}
- GET /internal/config/mitra-ping returns {require_ping,
  stale_after_seconds: 45, heartbeat_cadence_seconds: 30}
- PATCH with stale_after_seconds=20 → 422 with cadence message
- PATCH with stale_after_seconds=120 → 200, persisted
- Env override (=60, blank, "foo") parses correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:39:59 +08:00
1653482d54 Client: mirror branded notification sound to customer app
The customer app now uses the same halobestie_notif.ogg as the mitra
app (shipped in the previous commit). Channel ID unified across both
apps so backend FCM stops branching per recipient.

- client_app: same channel bump (chat_messages → halobestie_chat_v1)
  + RawResourceAndroidNotificationSound binding, both at channel-
  create time and per-notification details. .ogg copied to
  client_app/android/app/src/main/res/raw/halobestie_notif.ogg
  (same 32 KB asset, identical file).
- Backend: drop the per-recipientType channel ID branch; everyone
  targets halobestie_chat_v1 now.

Verified on emulator-5554 (customer): dumpsys shows the channel
bound to android.resource://com.mybestie/raw/halobestie_notif.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:05:05 +08:00
9de6b8a78f Mitra: branded notification sound (halobestie_notif.ogg)
Drops the system notification ding for incoming mitra FCM (Curhat Baru,
Permintaan Perpanjang) and plays the HaloBestie audio mark instead.

Source: a 2.8s mono AAC inside a 3GPP container the user supplied;
converted to 32 KB OGG (Vorbis q5) for Android since the channel-sound
API needs `res/raw/<name>.<ext>` and OGG is the smallest universally
supported short-sound format on Android 5+.

- mitra_app: bump notification channel ID from `chat_messages` to
  `halobestie_chat_v1` (Android binds channel sound at create time
  on API 26+, so existing installs with the old channel need a fresh
  ID to pick up the new sound — can't mutate in place). Bind
  RawResourceAndroidNotificationSound('halobestie_notif') at both
  channel-create time and per-notification details (latter covers
  API 24/25 where channels don't exist).
- Backend: branch FCM `android.notification.channelId` by recipient
  type — mitras → `halobestie_chat_v1`, customers → `chat_messages`
  (unchanged). Customer app keeps system sound until/unless we ship
  a customer-side sound too.

Verified on emulator-5556 via `adb shell dumpsys notification` — the
new channel resolves to
`android.resource://com.mybestie.mitra/raw/halobestie_notif`. The OGG
ships inside the APK (32092 bytes, confirmed via `unzip -l`).

Follow-up (iOS): bundle the same sound as `.caf` under ios/Runner +
register as a Runner-target resource in pbxproj + reference filename
in the APS payload. Deferred until iOS testing comes back into scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:01:02 +08:00
9fa4724b2a Mitra Profil: WhatsApp + Telegram brand glyphs
Replaces the generic chat_bubble + send Material icons with the
official WhatsApp + Telegram glyphs from font_awesome_flutter. Adds
the package as a runtime dep; FA brand glyphs are CC BY 4.0 and the
package itself is MIT.

Visual style is kept consistent with the other rows (pink-soft tile
backing, brand-pink glyph fill) rather than full-brand colors —
matches the figma's monochrome tile pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:36:16 +08:00
31da57d218 Mitra Profil: drop handle subtitle on WA/TG rows
User feedback — the wa.me/... and t.me/... subtitles under "Chat
WhatsApp Kami" / "Chat Telegram Kami" leaked the raw URL into the UI.
Just the label now, matching how typical "contact us" menu entries
read. Tap still launches the deeplink from backend config.

Drop the unused `SupportHandle.displayHandle` getter that produced the
scheme-stripped subtitle — no other call site.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:32:46 +08:00
10699d1ad1 Mitra Profil: WA/TG contacts + Keluar-only (no Hapus Akun)
Replaces the placeholder "Hubungi Koordinator" row with two real
contacts pulled from backend config (support_handles_json), and drops
the "Hapus Akun" CTA. Mirrors the figma BestieProfile design but uses
the same WA/TG channel as the customer Tanya Admin sheet — business
decided the same ops team triages both audiences.

Backend:
- Promote support-handles route from /api/client to /api/shared
  (renamed file + export). Both apps now consume the same endpoint;
  hitting /api/client/* from mitra would violate the per-app
  convention in mitra_app/CLAUDE.md.
- client_app provider updated to /api/shared/support-handles.

Mitra app:
- New support_handles_provider mirroring the client_app one. Adds a
  `displayHandle` getter that strips the URL scheme for the subtitle
  ("https://wa.me/X" → "wa.me/X", "https://t.me/Y" → "t.me/Y") so the
  row looks like the figma without exposing raw URLs.
- Profil screen now lists: Chat WhatsApp Kami, Chat Telegram Kami,
  Syarat & Ketentuan, Kebijakan Privasi. Danger zone simplified to
  Keluar only — mitras request account deletion through the same
  WA/TG channels (no separate self-service path).
- url_launcher added as a runtime dep, launches deeplinks in
  externalApplication mode with graceful snackbar fallback when
  parsing or launching fails.

Updates [[feedback-mitra-internal-audience]] — pre-login rule still
holds (no admin CTAs on S3a/S3b/AccountInactive), but the post-login
Profil tab now does surface WA/TG. Overrides decided 2026-05-21.

Verified on emulator-5556: Profil tab renders both rows with handles
from `wa.me/6285173310010` + `t.me/halobestie`, Keluar present, no
Hapus Akun button.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:32:21 +08:00
e4bffe1a71 Extension request: WS→FCM fallback + chat-recovery on connect
Today the customer's "Perpanjang" only reaches the mitra via session-
scoped WS. If the mitra is on Home/Undangan, in a different session, or
backgrounded, the WS send no-ops and the 10s safeguard timeout fires
auto-reject (or auto-approve if the mitra happens to also have an
active general WS, depending on config) — either way the mitra never
saw the request.

Backend:
- extension.service.js::requestExtension now falls back to FCM via
  notification.service when the mitra isn't on the session WS. Mirrors
  the pairing notifyMitra pattern (Curhat Baru). Customer display name
  is pulled into the session lookup for the FCM body.
- shared.chat.routes.js: /chat/:sessionId/info now returns
  pending_extension (extension_id, duration_minutes, price,
  requested_at, expires_at, timeout_seconds) so the chat screen can
  rehydrate the accept/reject UI after a cold-start FCM tap. expires_at
  is derived from requested_at + extension_timeout_seconds config.

Mitra app:
- mitra_chat_notifier.dart::connect parses pending_extension from /info
  and seeds MitraChatConnectedData.extensionRequest — the existing
  _buildExtensionView renders unchanged.
- notification_service.dart::_navigateFromMessage handles
  type=extension_request → pushes /chat/session/<id>. Composes with
  the new /info pending_extension to bring the mitra straight into the
  accept/reject view.

Verified end-to-end on dev backend (FCM call returned sent=true; /info
returns pending_extension when within timeout window). Visual delivery
on emulator-5556 deferred — API 24 AVD queues FCM 5-30 min per
feedback-emulator-avd-versions.

Out of scope (follow-ups):
- Customer-side FCM for EXTENSION_RESPONSE (accepted/rejected/timeout)
- Perpanjang tab list endpoint + Flutter provider + UI

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 13:24:40 +08:00
368d18a0bf Mitra: regression coverage for back-press-during-session-ended
Verified the 2026-05-15 disconnect() fix end-to-end on emulator-5556:
mitra logs in → online → accepts blast → backend force-expires →
goodbye composer renders → back-press → lands on Bestie Home with
online status preserved, zero flutter:E in logcat.

- ts-mitra-3-08-back_press_after_session_expired_no_red_screen.yaml
  codifies the repro for Maestro. Extends ts-mitra-3-04 with the
  back-tap + home-assertion + red-screen guard.
- mitra_app/CLAUDE.md adds a Pitfall section beneath the existing
  "no ref in dispose" rule: never mutate notifier state synchronously
  from deactivate() cleanup — wrap in
  SchedulerBinding.addPostFrameCallback or Riverpod throws "Tried to
  modify a provider while the widget tree was building" during the
  back-nav teardown.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 11:32:07 +08:00
34a8f7154e gitignore: match agent-memory at any depth
The previous `.claude/agent-memory/` pattern only matched at repo root.
backend/.claude/agent-memory/ was still showing as untracked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 11:15:26 +08:00
fbc94daac7 Mitra Bestie §1–§3: shell + Undangan + popup + chat polish
Brings the mitra app to figma-bestie parity for Home (§1), Undangan
inbox with Curhat Baru + Perpanjang tabs (§2), and the incoming-popup
+ active-chat flow (§3). Home now lives inside a StatefulShellRoute
with BestieTabBar so Profil + Undangan + Home share one shell.

- Shell: features/shell/ (StatefulShellRoute, BestieTabBar, 3 branches)
- Undangan: features/undangan/ — Curhat Baru reads
  chatRequestProvider.pendingInvites; row Terima delegates accept to
  the notifier and ChatRequestOverlay owns nav (no double-push).
  Perpanjang tab stubbed (empty state) until backend exposes
  pendingExtensionsProvider.
- Profil: features/profile/ — Bestie-styled stub
- Home: refactored to body-only (shell owns chrome)
- Popup: chat_request_overlay + chat_request_notifier updated to
  serve the list rows, not just the modal
- Chat: mitra_chat_screen polish
- Theme: accentAmber tokens for the Perpanjang tab + halo_orb widget
  (loading spinner used by undangan list states)
- Login: replace broken GoRouterState location guard with
  _expectOtpPush flag — was stacking duplicate /otp pages on OTP
  resend (see project-otp-nav-bug-fixed-2026-05-21)

Maestro:
- 17 new flows under .maestro/flows/ts-mitra-{1,2,3}-* covering home
  online/offline variants, undangan empty/populated/tolak states,
  popup curhat-baru → accept → chat → ended banner, plus popup
  dismiss/expire/cancelled edge cases
- 4 new §A OTP flows (07/08/09/10) for invalid/mismatch/expired/cooldown
- Helper scripts: force_mitra_online/offline, force_pairing_timeout,
  force_session_expires_at, delete_mitra_status_row,
  customer_blast_now (js), customer_cancel_latest_blast
- Backend: POST /internal/_test/delete-mitra-status-row supports the
  "fresh mitra with no status row" test setup

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 11:14:30 +08:00
fcb8eaa505 App ID + launcher icon rename: halobestie.* → mybestie
- Customer: com.halobestie.client.client_app → com.mybestie
- Mitra:    com.halobestie.mitra.mitra_app  → com.mybestie.mitra
- iOS bundle IDs renamed to match (no .clientApp/.mitra camelCase legacy)

Mechanical rename touches Android build.gradle/Manifest/MainActivity
package, iOS pbxproj/Info.plist bundle IDs, Firebase configs
(google-services.json + GoogleService-Info.plist + firebase_options.dart),
new HaloBestie/Mitra launcher icons via flutter_launcher_icons (pubspec
config + adaptive-icon resources + AppIcon imageset), and the appId
references in every customer maestro flow + both .maestro/config.yaml
files. brandLogoBg (#FF699F) added to halo_tokens for the launcher pink.

Followup: re-register apps in Firebase consoles using the new package IDs;
strategy memo at project-firebase-env-strategy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 11:13:47 +08:00
194 changed files with 5864 additions and 794 deletions

19
.gitignore vendored
View File

@@ -1,6 +1,8 @@
node_modules/ node_modules/
dist/ dist/
.env .env
.env.local
.env.*.local
*.log *.log
.dart_tool/ .dart_tool/
.packages .packages
@@ -11,6 +13,23 @@ build/
.flutter-plugins-dependencies .flutter-plugins-dependencies
bugreport-*.zip bugreport-*.zip
# Claude per-project agent memory (local-only, machine-specific) — nested
# patterns need the `**/` prefix to match below the repo root (e.g.
# backend/.claude/agent-memory/).
**/.claude/agent-memory/
# Maestro local debug artifacts (screenshots dumped at app root by
# `--debug-output` / takeScreenshot; results journal regenerated each run)
mitra_app/*.png
mitra_app/.maestro/RESULTS.md
client_app/*.png
client_app/.maestro/RESULTS.md
# Stray google-services.json at repo root (real ones live under
# {client_app,mitra_app}/android/app/). Usually a `flutterfire configure`
# misdrop — keep local but don't commit.
/google-services.json
# Figma design dump (do not check in) # Figma design dump (do not check in)
requirement/Figma.zip requirement/Figma.zip
requirement/Figma/ requirement/Figma/

View File

@@ -48,3 +48,22 @@ Internal listener must never be exposed to the public internet.
- Use Fastify plugins for shared middleware (auth, error handling, logging) - Use Fastify plugins for shared middleware (auth, error handling, logging)
- Business logic lives in `services/` — never directly in route handlers - Business logic lives in `services/` — never directly in route handlers
- Never reintroduce Firebase Auth. `firebase-admin` is FCM-only; do not import `.auth()` from it. - Never reintroduce Firebase Auth. `firebase-admin` is FCM-only; do not import `.auth()` from it.
## Config-Source Convention
Two distinct knob-types exist; do not conflate them:
- **DB-stored** (`app_config` table, mutable via CC SettingsPage at runtime): used for operator-tunable values that may change between deploys without a code roll — `mitra_stale_after_seconds`, `extension_timeout_seconds`, `pricing_tiers`, `support_handles_json`, `max_customers_per_mitra`, etc. Read via getters in `services/config.service.js`. Cache invalidation goes through `valkey` pub/sub when needed.
- **Env-driven** (`process.env`, set per deployment via `.env` or Cloud Run env vars): used for deploy-fixed values that should never differ between operator actions — `MITRA_HEARTBEAT_CADENCE_SECONDS`, `FIREBASE_SERVICE_ACCOUNT_PATH`, `AUTH_JWT_SECRET`, `DATABASE_URL`. Always expose via a getter helper with a sane default + numeric parsing (see `getMitraHeartbeatCadenceSeconds` in config.service.js for the pattern).
When a new value needs to flow from CC → app, prefer DB. When it's a deploy-fixed contract (e.g. heartbeat cadence the apps must honor, Xendit credentials, callback tokens), prefer env. CC inputs that depend on env values (e.g. min/max validation) read the env-derived value via the same config endpoint that surfaces the DB value, and the PATCH route validates against it.
## FCM Channel Convention
Single channel `halobestie_chat_v1` is shared by both apps (registered in each app's `core/notifications/notification_service.dart`) and ships the branded `halobestie_notif.ogg` sound. Backend FCM payloads should always target this channel ID via `android.notification.channelId`:
```js
android: { priority: 'high', notification: { channelId: 'halobestie_chat_v1' } }
```
Do not introduce per-recipient or per-feature channels lightly. If a new sound is required (e.g. payment alert), bump the channel ID (`halobestie_chat_v2`) and update both apps simultaneously — Android binds channel sound at create-time on API 26+, so mutating the existing channel doesn't pick up the new sound for installed users.

View File

@@ -13,7 +13,7 @@ import { clientPaymentRoutes } from './routes/public/client.payment.routes.js'
import { clientMitraAvailabilityRoutes } from './routes/public/client.mitra-availability.routes.js' import { clientMitraAvailabilityRoutes } from './routes/public/client.mitra-availability.routes.js'
import { publicBestieAvailabilityRoutes } from './routes/public/public.bestie-availability.routes.js' import { publicBestieAvailabilityRoutes } from './routes/public/public.bestie-availability.routes.js'
import { clientOnboardingRoutes } from './routes/public/client.onboarding.routes.js' import { clientOnboardingRoutes } from './routes/public/client.onboarding.routes.js'
import { clientSupportRoutes } from './routes/public/client.support.routes.js' import { sharedSupportRoutes } from './routes/public/shared.support.routes.js'
import { sharedChatRoutes } from './routes/public/shared.chat.routes.js' import { sharedChatRoutes } from './routes/public/shared.chat.routes.js'
import { errorHandler } from './plugins/error-handler.js' import { errorHandler } from './plugins/error-handler.js'
import { registerWebSocketPlugin, registerWebSocketRoute } from './plugins/websocket.js' import { registerWebSocketPlugin, registerWebSocketRoute } from './plugins/websocket.js'
@@ -38,10 +38,10 @@ export const buildPublicApp = async () => {
app.register(clientPaymentRoutes, { prefix: '/api/client/payment-sessions' }) app.register(clientPaymentRoutes, { prefix: '/api/client/payment-sessions' })
app.register(clientMitraAvailabilityRoutes, { prefix: '/api/client/mitra-availability' }) app.register(clientMitraAvailabilityRoutes, { prefix: '/api/client/mitra-availability' })
app.register(publicBestieAvailabilityRoutes, { prefix: '/api/public/bestie' }) app.register(publicBestieAvailabilityRoutes, { prefix: '/api/public/bestie' })
// Phase 4: onboarding-state + support handles. Both are tiny so they live in their // Onboarding-state stays client-only (anonymous customer flow). Support
// own files rather than bloating client.auth.routes / shared.config.routes. // handles are shared — both client and mitra apps link the same WA/TG.
app.register(clientOnboardingRoutes, { prefix: '/api/client' }) app.register(clientOnboardingRoutes, { prefix: '/api/client' })
app.register(clientSupportRoutes, { prefix: '/api/client' }) app.register(sharedSupportRoutes, { prefix: '/api/shared' })
// WebSocket route (registered at app level, not prefixed) // WebSocket route (registered at app level, not prefixed)
registerWebSocketRoute(app) registerWebSocketRoute(app)

View File

@@ -295,6 +295,18 @@ const migrate = async () => {
ON CONFLICT (key) DO NOTHING ON CONFLICT (key) DO NOTHING
` `
// Mitra reachability — replaces the implicit `ping_interval * 3` grace
// window with an operator-facing "max heartbeat age" knob. The companion
// heartbeat cadence lives in env (MITRA_HEARTBEAT_CADENCE_SECONDS, default
// 30s). Default 45s keeps the same effective grace as the old 15s ping × 3.
// `mitra_ping_interval_seconds` is left in place (vestigial) — no live code
// path reads it anymore; safe to drop after one release.
await sql`
INSERT INTO app_config (key, value)
VALUES ('mitra_stale_after_seconds', '{"value": 45}')
ON CONFLICT (key) DO NOTHING
`
// --- Phase 3.2: Mitra Request Activity Log --- // --- Phase 3.2: Mitra Request Activity Log ---
await sql` await sql`

View File

@@ -442,6 +442,26 @@ export const internalTestRoutes = async (fastify) => {
return { ok: true, ...updated } return { ok: true, ...updated }
}) })
// Delete the mitra_online_status row for a given mitra — used by Maestro
// scenario flows that need to simulate a "freshly created mitra with NO
// status row yet" (the natural state right after seed_mitra and before
// any /api/mitra/status call from the app). The app's first /status call
// re-creates the row via ensureStatusRow() with the DB default
// is_online=false; this endpoint just rewinds to that pre-state.
//
// Body: { mitra_id }
fastify.post('/delete-mitra-status-row', async (request, reply) => {
const mitraId = request.body?.mitra_id
if (!mitraId) {
return reply.code(400).send({ error: 'mitra_id required in body' })
}
const result = await sql`
DELETE FROM mitra_online_status WHERE mitra_id = ${mitraId}
RETURNING mitra_id
`
return { ok: true, mitra_id: mitraId, deleted: result.length > 0 }
})
// Accept the most recent pending pairing notification, regardless of which // Accept the most recent pending pairing notification, regardless of which
// mitra it was sent to. Used by Maestro flows where the test doesn't know // mitra it was sent to. Used by Maestro flows where the test doesn't know
// (or care) which specific mitra should accept — e.g. TS-02 (blast where // (or care) which specific mitra should accept — e.g. TS-02 (blast where

View File

@@ -9,7 +9,7 @@ import {
getFreeTrialConfig, setFreeTrialConfig, getFreeTrialConfig, setFreeTrialConfig,
getExtensionTimeoutConfig, setExtensionTimeoutConfig, getExtensionTimeoutConfig, setExtensionTimeoutConfig,
getEarlyEndConfig, setEarlyEndConfig, getEarlyEndConfig, setEarlyEndConfig,
getMitraPingConfig, setMitraPingConfig, getMitraPingConfig, setMitraPingConfig, getMitraHeartbeatCadenceSeconds,
getSensitivityConfig, setSensitivityConfig, getSensitivityConfig, setSensitivityConfig,
getPaymentSessionTimeoutMinutes, setPaymentSessionTimeoutMinutes, getPaymentSessionTimeoutMinutes, setPaymentSessionTimeoutMinutes,
getReturningChatConfirmationTimeoutSeconds, setReturningChatConfirmationTimeoutSeconds, getReturningChatConfirmationTimeoutSeconds, setReturningChatConfirmationTimeoutSeconds,
@@ -173,14 +173,23 @@ export const internalConfigRoutes = async (app) => {
app.patch('/mitra-ping', { app.patch('/mitra-ping', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')], preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
}, async (request, reply) => { }, async (request, reply) => {
const { require_ping, ping_interval_seconds } = request.body ?? {} const { require_ping, stale_after_seconds } = request.body ?? {}
if (require_ping !== undefined && typeof require_ping !== 'boolean') { if (require_ping !== undefined && typeof require_ping !== 'boolean') {
return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'require_ping must be a boolean' } }) return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'require_ping must be a boolean' } })
} }
if (ping_interval_seconds !== undefined && (typeof ping_interval_seconds !== 'number' || ping_interval_seconds < 5)) { if (stale_after_seconds !== undefined) {
return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'ping_interval_seconds must be a number >= 5' } }) const cadence = getMitraHeartbeatCadenceSeconds()
if (typeof stale_after_seconds !== 'number' || stale_after_seconds < cadence) {
return reply.code(422).send({
success: false,
error: {
code: 'VALIDATION_ERROR',
message: `stale_after_seconds must be a number >= heartbeat cadence (${cadence}s)`,
},
})
} }
const config = await setMitraPingConfig({ require_ping, ping_interval_seconds }) }
const config = await setMitraPingConfig({ require_ping, stale_after_seconds })
return reply.send({ success: true, data: config }) return reply.send({ success: true, data: config })
}) })

View File

@@ -56,7 +56,35 @@ export const sharedChatRoutes = async (app) => {
return reply.code(404).send({ success: false, error: { code: 'NOT_FOUND', message: 'Session not found' } }) return reply.code(404).send({ success: false, error: { code: 'NOT_FOUND', message: 'Session not found' } })
} }
const goodbyeSubmittedByMe = await hasUserSubmittedClosure(sessionId, request.userType) const goodbyeSubmittedByMe = await hasUserSubmittedClosure(sessionId, request.userType)
return reply.send({ success: true, data: { ...session, goodbye_submitted_by_me: goodbyeSubmittedByMe } }) // Surface any pending extension so the mitra chat screen can recover the
// _buildExtensionView state after a cold-start via FCM tap — without this,
// the WS EXTENSION_REQUEST frame fired earlier has nothing to bind to.
const [pendingExt] = await sql`
SELECT id, requested_duration_minutes, requested_price, requested_at
FROM session_extensions
WHERE session_id = ${sessionId} AND status = 'pending'
ORDER BY requested_at DESC
LIMIT 1
`
let pending_extension = null
if (pendingExt) {
const { getExtensionTimeoutConfig } = await import('../../services/config.service.js')
const { extension_timeout_seconds } = await getExtensionTimeoutConfig()
const requestedAtMs = new Date(pendingExt.requested_at).getTime()
const expiresAtMs = requestedAtMs + extension_timeout_seconds * 1000
pending_extension = {
extension_id: pendingExt.id,
duration_minutes: pendingExt.requested_duration_minutes,
price: pendingExt.requested_price,
requested_at: pendingExt.requested_at,
expires_at: new Date(expiresAtMs).toISOString(),
timeout_seconds: extension_timeout_seconds,
}
}
return reply.send({
success: true,
data: { ...session, goodbye_submitted_by_me: goodbyeSubmittedByMe, pending_extension },
})
}) })
// Get full transcript (read-only, for history) // Get full transcript (read-only, for history)

View File

@@ -2,11 +2,15 @@ import { authenticate } from '../../plugins/auth.js'
import { getSupportHandles } from '../../services/config.service.js' import { getSupportHandles } from '../../services/config.service.js'
/** /**
* Phase 4 Tanya Admin sheet handles. Sourced from `app_config.support_handles_json`, * Support channels (WA + Telegram). Sourced from `app_config.support_handles_json`,
* editable by CC. Authenticated so unauthenticated callers can't enumerate the * editable by CC. Authenticated so unauthenticated callers can't enumerate the
* support channels (rate-limit hardening, not a secret). * channels (rate-limit hardening, not a secret).
*
* Originally registered under /api/client (Phase 4 Tanya Admin sheet). Promoted
* to /api/shared when the mitra Profil screen started linking the same WA/TG
* contacts same data, both audiences.
*/ */
export const clientSupportRoutes = async (app) => { export const sharedSupportRoutes = async (app) => {
app.get('/support-handles', { preHandler: authenticate }, async (_request, reply) => { app.get('/support-handles', { preHandler: authenticate }, async (_request, reply) => {
const handles = await getSupportHandles() const handles = await getSupportHandles()
return reply.send({ success: true, data: handles }) return reply.send({ success: true, data: handles })

View File

@@ -128,18 +128,38 @@ export const getEarlyEndConfig = async () => {
} }
} }
// --- Phase 3.1: Mitra Ping Config --- // --- Mitra reachability config ---
//
// Two separate concerns, deliberately decoupled:
// - heartbeat_cadence_seconds: how often the mitra app sends a heartbeat.
// Fixed per backend deployment via the MITRA_HEARTBEAT_CADENCE_SECONDS
// env (default 30). The mitra app reads this from /api/mitra/status and
// uses it directly as its Timer.periodic interval.
// - stale_after_seconds: how long the backend tolerates silence before
// marking a mitra offline. DB-stored, CC-tunable. Must be >= the
// heartbeat cadence (CC PATCH validates this).
//
// `require_ping` stays as the master switch — when false, the auto-offline
// sweep is skipped entirely and mitras stay online forever once they toggle.
export const getMitraHeartbeatCadenceSeconds = () => {
const raw = process.env.MITRA_HEARTBEAT_CADENCE_SECONDS
if (!raw || raw.trim() === '') return 30
const parsed = Number.parseInt(raw, 10)
return Number.isFinite(parsed) && parsed >= 5 ? parsed : 30
}
export const getMitraPingConfig = async () => { export const getMitraPingConfig = async () => {
const [requireRow] = await sql`SELECT value FROM app_config WHERE key = 'require_mitra_ping'` const [requireRow] = await sql`SELECT value FROM app_config WHERE key = 'require_mitra_ping'`
const [intervalRow] = await sql`SELECT value FROM app_config WHERE key = 'mitra_ping_interval_seconds'` const [staleRow] = await sql`SELECT value FROM app_config WHERE key = 'mitra_stale_after_seconds'`
return { return {
require_ping: requireRow?.value?.value ?? true, require_ping: requireRow?.value?.value ?? true,
ping_interval_seconds: intervalRow?.value?.value ?? 15, stale_after_seconds: staleRow?.value?.value ?? 45,
heartbeat_cadence_seconds: getMitraHeartbeatCadenceSeconds(),
} }
} }
export const setMitraPingConfig = async ({ require_ping, ping_interval_seconds }) => { export const setMitraPingConfig = async ({ require_ping, stale_after_seconds }) => {
if (require_ping !== undefined) { if (require_ping !== undefined) {
await sql` await sql`
INSERT INTO app_config (key, value, updated_at) INSERT INTO app_config (key, value, updated_at)
@@ -147,10 +167,10 @@ export const setMitraPingConfig = async ({ require_ping, ping_interval_seconds }
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
` `
} }
if (ping_interval_seconds !== undefined) { if (stale_after_seconds !== undefined) {
await sql` await sql`
INSERT INTO app_config (key, value, updated_at) INSERT INTO app_config (key, value, updated_at)
VALUES ('mitra_ping_interval_seconds', ${sql.json({ value: ping_interval_seconds })}, NOW()) VALUES ('mitra_stale_after_seconds', ${sql.json({ value: stale_after_seconds })}, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
` `
} }

View File

@@ -3,6 +3,7 @@ import { sendToSessionParticipant, isUserOnlineWs } from '../plugins/websocket.j
import { extendSessionTimer, clearClosureGraceTimer, startClosureGraceTimer } from './session-timer.service.js' import { extendSessionTimer, clearClosureGraceTimer, startClosureGraceTimer } from './session-timer.service.js'
import { isMitraReachable } from './mitra-status.service.js' import { isMitraReachable } from './mitra-status.service.js'
import { consumePaymentSession, failPaymentSession, getPaymentSession } from './payment.service.js' import { consumePaymentSession, failPaymentSession, getPaymentSession } from './payment.service.js'
import { sendPushNotification } from './notification.service.js'
import { import {
getExtensionTimeoutConfig, getExtensionTimeoutConfig,
getExtensionDefaultActionOnTimeout, getExtensionDefaultActionOnTimeout,
@@ -48,11 +49,16 @@ const getExtensionTimeoutAction = async () => {
* (mitra explicit accept OR auto-approve fires). * (mitra explicit accept OR auto-approve fires).
*/ */
export const requestExtension = async (sessionId, customerId, { duration_minutes, price, extension_payment_session_id }) => { export const requestExtension = async (sessionId, customerId, { duration_minutes, price, extension_payment_session_id }) => {
// Verify session belongs to customer and is in an extendable state // Verify session belongs to customer and is in an extendable state.
// customer_display_name is pulled along for the FCM body when the mitra
// misses the WS frame.
const [session] = await sql` const [session] = await sql`
SELECT id, customer_id, mitra_id, status, topic_sensitivity FROM chat_sessions SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.topic_sensitivity,
WHERE id = ${sessionId} AND customer_id = ${customerId} c.display_name AS customer_display_name
AND status IN (${SessionStatus.ACTIVE}, ${SessionStatus.CLOSING}) FROM chat_sessions cs
INNER JOIN customers c ON c.id = cs.customer_id
WHERE cs.id = ${sessionId} AND cs.customer_id = ${customerId}
AND cs.status IN (${SessionStatus.ACTIVE}, ${SessionStatus.CLOSING})
` `
if (!session) { if (!session) {
throw Object.assign(new Error('Session not found or already ended'), { code: 'SESSION_NOT_ACTIVE', statusCode: 409 }) throw Object.assign(new Error('Session not found or already ended'), { code: 'SESSION_NOT_ACTIVE', statusCode: 409 })
@@ -103,8 +109,13 @@ export const requestExtension = async (sessionId, customerId, { duration_minutes
const timeoutMs = await getExtensionTimeoutMs() const timeoutMs = await getExtensionTimeoutMs()
const timeoutSeconds = Math.round(timeoutMs / 1000) const timeoutSeconds = Math.round(timeoutMs / 1000)
// Notify mitra — include current topic sensitivity so UI can highlight // Notify mitra — include current topic sensitivity so UI can highlight.
sendToSessionParticipant(sessionId, UserType.MITRA, { // If the mitra isn't on this session's chat WS (on Home/Undangan, in
// another chat, or app backgrounded), fall back to FCM. The session-
// scoped WS is the only channel that reaches the in-chat `_buildExtensionView`
// in real time; FCM gets them to /chat/session/:id, where chat connect
// restores the pending extension state via /chat/:sessionId/info.
const wsSent = sendToSessionParticipant(sessionId, UserType.MITRA, {
type: WsMessage.EXTENSION_REQUEST, type: WsMessage.EXTENSION_REQUEST,
extension_id: extension.id, extension_id: extension.id,
session_id: sessionId, session_id: sessionId,
@@ -114,6 +125,22 @@ export const requestExtension = async (sessionId, customerId, { duration_minutes
timeout_seconds: timeoutSeconds, timeout_seconds: timeoutSeconds,
}) })
if (!wsSent) {
await sendPushNotification(UserType.MITRA, session.mitra_id, {
title: 'Permintaan Perpanjang',
body: `${session.customer_display_name} mau lanjut +${duration_minutes} menit`,
data: {
type: WsMessage.EXTENSION_REQUEST,
session_id: sessionId,
extension_id: extension.id,
duration_minutes,
price,
timeout_seconds: timeoutSeconds,
action: 'open_extension',
},
})
}
// Notify customer that chat is paused // Notify customer that chat is paused
sendToSessionParticipant(sessionId, UserType.CUSTOMER, { sendToSessionParticipant(sessionId, UserType.CUSTOMER, {
type: WsMessage.SESSION_PAUSED, type: WsMessage.SESSION_PAUSED,

View File

@@ -96,7 +96,9 @@ export const getStatus = async (mitraId) => {
return { return {
...status, ...status,
require_ping: pingConfig.require_ping, require_ping: pingConfig.require_ping,
ping_interval_seconds: pingConfig.ping_interval_seconds, // The app reads this to set its Timer.periodic interval. Backend-fixed
// (via env), not operator-tunable.
heartbeat_cadence_seconds: pingConfig.heartbeat_cadence_seconds,
} }
} }
@@ -134,7 +136,12 @@ export const autoOfflineStaleMitras = async () => {
// If ping is not required, skip the auto-offline sweep entirely // If ping is not required, skip the auto-offline sweep entirely
if (!pingConfig.require_ping) return 0 if (!pingConfig.require_ping) return 0
const staleSeconds = pingConfig.ping_interval_seconds * 3 // stale_after_seconds is the operator-facing knob — what they set is what
// they get. No multiplier, no implicit "tolerate N missed heartbeats"
// contract baked in. The CC PATCH validates that the value is >= the env-
// driven heartbeat cadence so single missed pings can't flip a mitra
// offline.
const staleSeconds = pingConfig.stale_after_seconds
const stale = await sql` const stale = await sql`
UPDATE mitra_online_status UPDATE mitra_online_status
SET is_online = false, last_offline_at = NOW(), updated_at = NOW() SET is_online = false, last_offline_at = NOW(), updated_at = NOW()

View File

@@ -33,7 +33,10 @@ export const sendPushNotification = async (recipientType, recipientId, { title,
}, },
android: { android: {
priority: 'high', priority: 'high',
notification: { channelId: 'chat_messages' }, // Both apps register the same channel ID with the branded
// notification sound (halobestie_notif.ogg in res/raw). See each
// app's lib/core/notifications/notification_service.dart.
notification: { channelId: 'halobestie_chat_v1' },
}, },
apns: { apns: {
payload: { payload: {

View File

@@ -5,8 +5,8 @@
env: env:
# App identifiers — Android / iOS bundle IDs picked up automatically by `appId:` in flows. # App identifiers — Android / iOS bundle IDs picked up automatically by `appId:` in flows.
APP_ID_ANDROID: com.halobestie.client.client_app APP_ID_ANDROID: com.mybestie
APP_ID_IOS: com.halobestie.client.clientApp APP_ID_IOS: com.mybestie
# Backend the app talks to — must match what the installed APK was built with # Backend the app talks to — must match what the installed APK was built with
# (the `--dart-define=API_BASE_URL=...` value at build time). # (the `--dart-define=API_BASE_URL=...` value at build time).

View File

@@ -11,7 +11,7 @@
# Pre-req: client_app debug APK installed, backend reachable at # Pre-req: client_app debug APK installed, backend reachable at
# BACKEND_URL/BACKEND_INTERNAL_URL, NODE_ENV != 'production' (so the # BACKEND_URL/BACKEND_INTERNAL_URL, NODE_ENV != 'production' (so the
# /internal/_test/peek-otp + /internal/_test/reset-phone routes register). # /internal/_test/peek-otp + /internal/_test/reset-phone routes register).
appId: com.halobestie.client.client_app appId: com.mybestie
env: env:
TEST_PHONE: "+628155556677" TEST_PHONE: "+628155556677"
BACKEND_INTERNAL_URL: http://localhost:3001 BACKEND_INTERNAL_URL: http://localhost:3001

View File

@@ -14,7 +14,7 @@
# NOTE: numeric prefix conflicts with the existing # NOTE: numeric prefix conflicts with the existing
# 02_cta_disabled_when_no_mitra.yaml — Stage 9 will reorganize the flow # 02_cta_disabled_when_no_mitra.yaml — Stage 9 will reorganize the flow
# directory once the full Phase 4 suite lands. # directory once the full Phase 4 suite lands.
appId: com.halobestie.client.client_app appId: com.mybestie
env: env:
TEST_PHONE: "+628155557701" TEST_PHONE: "+628155557701"
BACKEND_INTERNAL_URL: http://localhost:3001 BACKEND_INTERNAL_URL: http://localhost:3001

View File

@@ -9,7 +9,7 @@
# #
# NOTE: numeric prefix conflicts with the existing 03_payment_to_chat_happy.yaml # NOTE: numeric prefix conflicts with the existing 03_payment_to_chat_happy.yaml
# — Stage 9 will reorganize the flow directory once the full Phase 4 suite lands. # — Stage 9 will reorganize the flow directory once the full Phase 4 suite lands.
appId: com.halobestie.client.client_app appId: com.mybestie
--- ---
- launchApp: - launchApp:
clearState: true clearState: true

View File

@@ -12,7 +12,7 @@
# #
# Run: # Run:
# maestro test client_app/.maestro/flows/04_payment_expired.yaml # maestro test client_app/.maestro/flows/04_payment_expired.yaml
appId: com.halobestie.client.client_app appId: com.mybestie
env: env:
TEST_PHONE: "+628155557704" TEST_PHONE: "+628155557704"
BACKEND_INTERNAL_URL: http://localhost:3001 BACKEND_INTERNAL_URL: http://localhost:3001

View File

@@ -9,7 +9,7 @@
# mitra is force-timed-out server-side regardless of availability. # mitra is force-timed-out server-side regardless of availability.
# 2. anonymity_enabled=true on the dev backend. # 2. anonymity_enabled=true on the dev backend.
# 3. NODE_ENV != 'production' (so /internal/_test/* routes register). # 3. NODE_ENV != 'production' (so /internal/_test/* routes register).
appId: com.halobestie.client.client_app appId: com.mybestie
env: env:
TEST_PHONE: "+628155557705" TEST_PHONE: "+628155557705"
BACKEND_INTERNAL_URL: http://localhost:3001 BACKEND_INTERNAL_URL: http://localhost:3001

View File

@@ -15,7 +15,7 @@
# #
# Run: # Run:
# maestro test client_app/.maestro/flows/08_returning_targeted.yaml # maestro test client_app/.maestro/flows/08_returning_targeted.yaml
appId: com.halobestie.client.client_app appId: com.mybestie
env: env:
TEST_PHONE: "+628155556677" TEST_PHONE: "+628155556677"
BACKEND_INTERNAL_URL: http://localhost:3001 BACKEND_INTERNAL_URL: http://localhost:3001

View File

@@ -21,7 +21,7 @@
# #
# Run: # Run:
# maestro test client_app/.maestro/flows/09_chat_tab.yaml # maestro test client_app/.maestro/flows/09_chat_tab.yaml
appId: com.halobestie.client.client_app appId: com.mybestie
env: env:
TEST_PHONE: "+628155556678" TEST_PHONE: "+628155556678"
BACKEND_INTERNAL_URL: http://localhost:3001 BACKEND_INTERNAL_URL: http://localhost:3001

View File

@@ -26,7 +26,7 @@
# simply suppresses the notification); the banner is the only # simply suppresses the notification); the banner is the only
# user-visible signal that they're missing alerts, which is what we # user-visible signal that they're missing alerts, which is what we
# assert below. # assert below.
appId: com.halobestie.client.client_app appId: com.mybestie
env: env:
TEST_PHONE: "+6281234567890" TEST_PHONE: "+6281234567890"
BACKEND_INTERNAL_URL: http://localhost:3001 BACKEND_INTERNAL_URL: http://localhost:3001

View File

@@ -10,7 +10,7 @@
# - Backend reachable; NODE_ENV != 'production'. # - Backend reachable; NODE_ENV != 'production'.
# - ≥1 mitra online (mitraAvailable gates the "aku mau curhat" CTA). # - ≥1 mitra online (mitraAvailable gates the "aku mau curhat" CTA).
# - first_session_discount is enabled in the pricing config (default). # - first_session_discount is enabled in the pricing config (default).
appId: com.halobestie.client.client_app appId: com.mybestie
env: env:
TEST_PHONE: "+6281234567890" TEST_PHONE: "+6281234567890"
BACKEND_INTERNAL_URL: http://localhost:3001 BACKEND_INTERNAL_URL: http://localhost:3001

View File

@@ -11,7 +11,7 @@
# 3. has_transacted is implicitly false (no chat_sessions → # 3. has_transacted is implicitly false (no chat_sessions →
# isCustomerEligibleForFirstSessionDiscount returns true). # isCustomerEligibleForFirstSessionDiscount returns true).
# 4. /payment/entry routes to /payment/discount-paywall (S6). # 4. /payment/entry routes to /payment/discount-paywall (S6).
appId: com.halobestie.client.client_app appId: com.mybestie
env: env:
TEST_PHONE: "+6281234567890" TEST_PHONE: "+6281234567890"
BACKEND_INTERNAL_URL: http://localhost:3001 BACKEND_INTERNAL_URL: http://localhost:3001

View File

@@ -11,7 +11,7 @@
# Pre-reqs: # Pre-reqs:
# - ≥1 mitra online (seed_history_session pairs with the most-recent # - ≥1 mitra online (seed_history_session pairs with the most-recent
# online mitra to write the chat_sessions row). # online mitra to write the chat_sessions row).
appId: com.halobestie.client.client_app appId: com.mybestie
env: env:
TEST_PHONE: "+6281234567890" TEST_PHONE: "+6281234567890"
BACKEND_INTERNAL_URL: http://localhost:3001 BACKEND_INTERNAL_URL: http://localhost:3001

View File

@@ -13,7 +13,7 @@
# This flow inlines the pre-OTP onboarding steps (instead of using the # This flow inlines the pre-OTP onboarding steps (instead of using the
# onboarding_new_user_verified subflow) because we want to enter wrong OTPs # onboarding_new_user_verified subflow) because we want to enter wrong OTPs
# rather than the peeked valid one. # rather than the peeked valid one.
appId: com.halobestie.client.client_app appId: com.mybestie
env: env:
TEST_PHONE: "+6281234567890" TEST_PHONE: "+6281234567890"
BACKEND_INTERNAL_URL: http://localhost:3001 BACKEND_INTERNAL_URL: http://localhost:3001

View File

@@ -8,7 +8,7 @@
# stays AuthAnonymousData). USP screen pushes /payment/method-pick # stays AuthAnonymousData). USP screen pushes /payment/method-pick
# directly when verified=false. Verifies onboardingIntent is NOT set # directly when verified=false. Verifies onboardingIntent is NOT set
# (it stays `recover` because we picked "curhat anonim", not "verifikasi"). # (it stays `recover` because we picked "curhat anonim", not "verifikasi").
appId: com.halobestie.client.client_app appId: com.mybestie
env: env:
TEST_PHONE: "+6281234567890" TEST_PHONE: "+6281234567890"
BACKEND_INTERNAL_URL: http://localhost:3001 BACKEND_INTERNAL_URL: http://localhost:3001

View File

@@ -23,7 +23,7 @@
# #
# Run: # Run:
# maestro test client_app/.maestro/flows/ts-customer-02-10-recover_via_masuk_existing_user_to_home.yaml # maestro test client_app/.maestro/flows/ts-customer-02-10-recover_via_masuk_existing_user_to_home.yaml
appId: com.halobestie.client.client_app appId: com.mybestie
env: env:
TEST_PHONE: "+6281234567890" TEST_PHONE: "+6281234567890"
EXISTING_NAME: "Returning User" EXISTING_NAME: "Returning User"

View File

@@ -18,7 +18,7 @@
# #
# Run: # Run:
# maestro test client_app/.maestro/flows/ts-01_returning_lama_online.yaml # maestro test client_app/.maestro/flows/ts-01_returning_lama_online.yaml
appId: com.halobestie.client.client_app appId: com.mybestie
env: env:
TEST_PHONE: "+6281234567890" TEST_PHONE: "+6281234567890"
BACKEND_INTERNAL_URL: http://localhost:3001 BACKEND_INTERNAL_URL: http://localhost:3001

View File

@@ -22,7 +22,7 @@
# #
# Run: # Run:
# maestro test client_app/.maestro/flows/ts-02_returning_lama_offline_blast.yaml # maestro test client_app/.maestro/flows/ts-02_returning_lama_offline_blast.yaml
appId: com.halobestie.client.client_app appId: com.mybestie
env: env:
TEST_PHONE: "+6281234567890" TEST_PHONE: "+6281234567890"
BACKEND_INTERNAL_URL: http://localhost:3001 BACKEND_INTERNAL_URL: http://localhost:3001

View File

@@ -22,7 +22,7 @@
# #
# Run: # Run:
# maestro test client_app/.maestro/flows/ts-03_returning_lama_offline_tanya_admin.yaml # maestro test client_app/.maestro/flows/ts-03_returning_lama_offline_tanya_admin.yaml
appId: com.halobestie.client.client_app appId: com.mybestie
env: env:
TEST_PHONE: "+6281234567890" TEST_PHONE: "+6281234567890"
BACKEND_INTERNAL_URL: http://localhost:3001 BACKEND_INTERNAL_URL: http://localhost:3001

View File

@@ -23,7 +23,7 @@
# #
# Run: # Run:
# maestro test client_app/.maestro/flows/ts-04_returning_baru_blast.yaml # maestro test client_app/.maestro/flows/ts-04_returning_baru_blast.yaml
appId: com.halobestie.client.client_app appId: com.mybestie
env: env:
TEST_PHONE: "+6281234567890" TEST_PHONE: "+6281234567890"
BACKEND_INTERNAL_URL: http://localhost:3001 BACKEND_INTERNAL_URL: http://localhost:3001

View File

@@ -18,7 +18,7 @@
# #
# Run: # Run:
# maestro test client_app/.maestro/flows/ts-05_payment_expired_retry_preserves_targeting.yaml # maestro test client_app/.maestro/flows/ts-05_payment_expired_retry_preserves_targeting.yaml
appId: com.halobestie.client.client_app appId: com.mybestie
env: env:
TEST_PHONE: "+6281234567890" TEST_PHONE: "+6281234567890"
BACKEND_INTERNAL_URL: http://localhost:3001 BACKEND_INTERNAL_URL: http://localhost:3001

View File

@@ -38,7 +38,7 @@
# #
# Run: # Run:
# maestro test client_app/.maestro/flows/ts-06_targeted_reject_fallback_to_blast.yaml # maestro test client_app/.maestro/flows/ts-06_targeted_reject_fallback_to_blast.yaml
appId: com.halobestie.client.client_app appId: com.mybestie
env: env:
TEST_PHONE: "+6281234567890" TEST_PHONE: "+6281234567890"
BACKEND_INTERNAL_URL: http://localhost:3001 BACKEND_INTERNAL_URL: http://localhost:3001

View File

@@ -27,7 +27,7 @@
# - Backend reachable; NODE_ENV != 'production'. # - Backend reachable; NODE_ENV != 'production'.
# - ≥1 mitra online (the seeded mitra acts as the blast acceptor and the # - ≥1 mitra online (the seeded mitra acts as the blast acceptor and the
# subsequent message sender). # subsequent message sender).
appId: com.halobestie.client.client_app appId: com.mybestie
env: env:
TEST_PHONE: "+6281234567890" TEST_PHONE: "+6281234567890"
BACKEND_INTERNAL_URL: http://localhost:3001 BACKEND_INTERNAL_URL: http://localhost:3001

View File

@@ -37,7 +37,7 @@
# - The currently-signed-in mitra must have a `fcm_token` row in # - The currently-signed-in mitra must have a `fcm_token` row in
# `mitras.fcm_token`; otherwise the FCM dispatch succeeds at the # `mitras.fcm_token`; otherwise the FCM dispatch succeeds at the
# backend code path but never reaches a device. # backend code path but never reaches a device.
appId: com.halobestie.client.client_app appId: com.mybestie
env: env:
TEST_PHONE: "+6281234567890" TEST_PHONE: "+6281234567890"
BACKEND_INTERNAL_URL: http://localhost:3001 BACKEND_INTERNAL_URL: http://localhost:3001

View File

@@ -9,7 +9,7 @@ plugins {
} }
android { android {
namespace = "com.halobestie.client.client_app" namespace = "com.mybestie"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion ndkVersion = flutter.ndkVersion
@@ -25,7 +25,7 @@ android {
defaultConfig { defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.halobestie.client.client_app" applicationId = "com.mybestie"
// You can update the following values to match your application needs. // You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config. // For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = 24 minSdk = 24

38
client_app/android/app/google-services.json Normal file → Executable file
View File

@@ -23,6 +23,44 @@
"other_platform_oauth_client": [] "other_platform_oauth_client": []
} }
} }
},
{
"client_info": {
"mobilesdk_app_id": "1:1068156046511:android:f30784f6b0423131b8185a",
"android_client_info": {
"package_name": "com.halobestie.mitra"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyDFWlLSWytqwI7LSdUbVrO7J5De9L2LV2U"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:1068156046511:android:4f8fe9a3c7c14c57b8185a",
"android_client_info": {
"package_name": "com.mybestie"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyDFWlLSWytqwI7LSdUbVrO7J5De9L2LV2U"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
} }
], ],
"configuration_version": "1" "configuration_version": "1"

View File

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

View File

@@ -1,4 +1,4 @@
package com.halobestie.mitra.mitra_app package com.mybestie
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 1000 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FF699F</color>
</resources>

BIN
client_app/assets/icons/logo.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

View File

@@ -496,7 +496,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = com.halobestie.client.clientApp; PRODUCT_BUNDLE_IDENTIFIER = com.mybestie;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@@ -513,7 +513,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.halobestie.client.clientApp.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = com.mybestie.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -531,7 +531,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.halobestie.client.clientApp.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = com.mybestie.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -547,7 +547,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.halobestie.client.clientApp.RunnerTests; PRODUCT_BUNDLE_IDENTIFIER = com.mybestie.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -558,7 +558,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++"; CLANG_CXX_LIBRARY = "libc++";
@@ -615,7 +615,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++"; CLANG_CXX_LIBRARY = "libc++";
@@ -679,7 +679,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = com.halobestie.client.clientApp; PRODUCT_BUNDLE_IDENTIFIER = com.mybestie;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -702,7 +702,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = com.halobestie.client.clientApp; PRODUCT_BUNDLE_IDENTIFIER = com.mybestie;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 86 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 114 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 146 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 97 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 144 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 228 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 114 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 276 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 342 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 342 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 493 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 412 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 169 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 600 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 525 B

4
client_app/ios/Runner/GoogleService-Info.plist Normal file → Executable file
View File

@@ -9,7 +9,7 @@
<key>PLIST_VERSION</key> <key>PLIST_VERSION</key>
<string>1</string> <string>1</string>
<key>BUNDLE_ID</key> <key>BUNDLE_ID</key>
<string>com.halobestie.client.clientApp</string> <string>com.mybestie</string>
<key>PROJECT_ID</key> <key>PROJECT_ID</key>
<string>halobestie-clone-dev</string> <string>halobestie-clone-dev</string>
<key>STORAGE_BUCKET</key> <key>STORAGE_BUCKET</key>
@@ -25,6 +25,6 @@
<key>IS_SIGNIN_ENABLED</key> <key>IS_SIGNIN_ENABLED</key>
<true></true> <true></true>
<key>GOOGLE_APP_ID</key> <key>GOOGLE_APP_ID</key>
<string>1:1068156046511:ios:c7786cedb9101d34b8185a</string> <string>1:1068156046511:ios:498ab71cbbbd6822b8185a</string>
</dict> </dict>
</plist> </plist>

View File

@@ -9,7 +9,7 @@
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>Client App</string> <string>HaloBestie</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
@@ -17,7 +17,7 @@
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string> <string>6.0</string>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>client_app</string> <string>HaloBestie</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>

View File

@@ -8,11 +8,17 @@ class NotificationService {
static final _localNotifications = FlutterLocalNotificationsPlugin(); static final _localNotifications = FlutterLocalNotificationsPlugin();
static GoRouter? _router; static GoRouter? _router;
// Channel ID bumped (`chat_messages` → `halobestie_chat_v1`) when the
// branded notification sound was introduced. Android binds sound to a
// channel at create time on API 26+, so an existing channel can't pick
// up a new sound — a fresh ID is the only way. Backend FCM payloads
// target the same ID — see backend/src/services/notification.service.js.
static const _channel = AndroidNotificationChannel( static const _channel = AndroidNotificationChannel(
'chat_messages', 'halobestie_chat_v1',
'Chat Messages', 'Chat HaloBestie',
description: 'Notifications for incoming chat messages', description: 'Notifications for incoming chat messages and pairing requests',
importance: Importance.high, importance: Importance.high,
sound: RawResourceAndroidNotificationSound('halobestie_notif'),
); );
static Future<void> initialize(GoRouter router) async { static Future<void> initialize(GoRouter router) async {
@@ -60,6 +66,9 @@ class NotificationService {
channelDescription: _channel.description, channelDescription: _channel.description,
importance: Importance.high, importance: Importance.high,
priority: Priority.high, priority: Priority.high,
// API 26+ ignores this in favor of the channel's sound; included
// for the API 24/25 path where channels don't exist yet.
sound: const RawResourceAndroidNotificationSound('halobestie_notif'),
), ),
iOS: const DarwinNotificationDetails( iOS: const DarwinNotificationDetails(
presentAlert: true, presentAlert: true,

View File

@@ -22,6 +22,9 @@ class HaloTokens {
static const Color brandDark = Color(0xFF8C3255); static const Color brandDark = Color(0xFF8C3255);
static const Color brandSoft = Color(0xFFF7E4E9); static const Color brandSoft = Color(0xFFF7E4E9);
static const Color brandSofter = Color(0xFFFBEFF3); static const Color brandSofter = Color(0xFFFBEFF3);
// Launcher-icon background. Use this pink behind monochrome/white logos.
// For full-color logos, use `surface` (#FFFFFF) as the icon background.
static const Color brandLogoBg = Color(0xFFFF699F);
static const Color accent = Color(0xFFF7B26A); static const Color accent = Color(0xFFF7B26A);
static const Color accentSoft = Color(0xFFFCEAD3); static const Color accentSoft = Color(0xFFFCEAD3);
static const Color mint = Color(0xFFB8DBC8); static const Color mint = Color(0xFFB8DBC8);

View File

@@ -27,7 +27,7 @@ class SupportHandles {
final supportHandlesProvider = FutureProvider<SupportHandles>((ref) async { final supportHandlesProvider = FutureProvider<SupportHandles>((ref) async {
final api = ref.read(apiClientProvider); final api = ref.read(apiClientProvider);
final response = await api.get('/api/client/support-handles'); final response = await api.get('/api/shared/support-handles');
final data = response['data'] as Map<String, dynamic>? ?? const {}; final data = response['data'] as Map<String, dynamic>? ?? const {};
return SupportHandles.fromJson(data); return SupportHandles.fromJson(data);
}); });

View File

@@ -63,6 +63,6 @@ class DefaultFirebaseOptions {
messagingSenderId: '1068156046511', messagingSenderId: '1068156046511',
projectId: 'halobestie-clone-dev', projectId: 'halobestie-clone-dev',
storageBucket: 'halobestie-clone-dev.firebasestorage.app', storageBucket: 'halobestie-clone-dev.firebasestorage.app',
iosBundleId: 'com.halobestie.client.clientApp', iosBundleId: 'com.mybestie',
); );
} }

View File

@@ -33,6 +33,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.13.4" version: "0.13.4"
archive:
dependency: transitive
description:
name: archive
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
url: "https://pub.dev"
source: hosted
version: "4.0.9"
args: args:
dependency: transitive dependency: transitive
description: description:
@@ -358,6 +366,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.20.5" version: "0.20.5"
flutter_launcher_icons:
dependency: "direct dev"
description:
name: flutter_launcher_icons
sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea"
url: "https://pub.dev"
source: hosted
version: "0.13.1"
flutter_lints: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@@ -607,6 +623,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.2" version: "4.1.2"
image:
dependency: transitive
description:
name: image
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
url: "https://pub.dev"
source: hosted
version: "4.8.0"
io: io:
dependency: transitive dependency: transitive
description: description:
@@ -879,6 +903,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.5.2" version: "1.5.2"
posix:
dependency: transitive
description:
name: posix
sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
url: "https://pub.dev"
source: hosted
version: "6.5.0"
pub_semver: pub_semver:
dependency: transitive dependency: transitive
description: description:

View File

@@ -58,12 +58,29 @@ dev_dependencies:
build_runner: ^2.4.13 build_runner: ^2.4.13
custom_lint: ^0.7.0 custom_lint: ^0.7.0
riverpod_lint: ^2.6.2 riverpod_lint: ^2.6.2
# Generates launcher icons for Android + iOS from a single source PNG.
# Config block below; run `dart run flutter_launcher_icons` to regenerate.
flutter_launcher_icons: ^0.13.1
# In-repo lint rules — lives at the repo root so client_app + mitra_app # In-repo lint rules — lives at the repo root so client_app + mitra_app
# share the same set. Adds `no_ref_in_dispose` and any future repo-wide # share the same set. Adds `no_ref_in_dispose` and any future repo-wide
# guardrails. See halo_lints/lib/halo_lints.dart. # guardrails. See halo_lints/lib/halo_lints.dart.
halo_lints: halo_lints:
path: ../halo_lints path: ../halo_lints
# Launcher-icon config. Background color mirrors HaloTokens.brandLogoBg
# (#FF699F) from lib/core/theme/halo_tokens.dart — the documented bg for
# monochrome/white logos. If the source logo ever becomes full-color,
# switch both values to `#FFFFFF` (HaloTokens.surface).
flutter_launcher_icons:
android: true
ios: true
image_path: "assets/icons/logo.png"
remove_alpha_ios: true
background_color_ios: "#FF699F"
min_sdk_android: 24
adaptive_icon_background: "#FF699F"
adaptive_icon_foreground: "assets/icons/logo.png"
flutter: flutter:
uses-material-design: true uses-material-design: true
assets: assets:

View File

@@ -452,7 +452,7 @@ export default function SettingsPage() {
<section style={{ marginBottom: 24 }}> <section style={{ marginBottom: 24 }}>
<h2>Mitra Online Status (Ping)</h2> <h2>Mitra Online Status (Ping)</h2>
<p>Konfigurasi apakah mitra harus mengirim ping (heartbeat) untuk tetap online.</p> <p>Mitra dianggap online selama heartbeat terakhir berusia ambang batas. Cadence (frekuensi ping aplikasi) di-fix oleh server lewat env var.</p>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}> <label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<input <input
type="checkbox" type="checkbox"
@@ -465,21 +465,27 @@ export default function SettingsPage() {
<p style={{ fontSize: 12, color: '#666', marginBottom: 8 }}> <p style={{ fontSize: 12, color: '#666', marginBottom: 8 }}>
Jika dinonaktifkan, mitra akan tetap online tanpa perlu mengirim ping. QC bertanggung jawab atas kualitas layanan mitra. Jika dinonaktifkan, mitra akan tetap online tanpa perlu mengirim ping. QC bertanggung jawab atas kualitas layanan mitra.
</p> </p>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<label>Interval Ping:</label> <label>Ambang offline (heartbeat terakhir lebih lama dari):</label>
<input <input
type="number" type="number"
min="5" min={mpData?.heartbeat_cadence_seconds ?? 30}
value={mpData?.ping_interval_seconds ?? 15} value={mpData?.stale_after_seconds ?? 45}
onChange={e => { onChange={e => {
const val = parseInt(e.target.value, 10) const val = parseInt(e.target.value, 10)
if (val >= 5) mpMutation.mutate({ ping_interval_seconds: val }) const floor = mpData?.heartbeat_cadence_seconds ?? 30
if (Number.isFinite(val) && val >= floor) {
mpMutation.mutate({ stale_after_seconds: val })
}
}} }}
disabled={mpMutation.isPending} disabled={mpMutation.isPending}
style={{ width: 80 }} style={{ width: 80 }}
/> />
<span>detik</span> <span>detik</span>
</div> </div>
<p style={{ fontSize: 12, color: '#666', marginBottom: 0 }}>
Cadence ping mitra: <strong>{mpData?.heartbeat_cadence_seconds ?? 30} detik</strong> (server-set via MITRA_HEARTBEAT_CADENCE_SECONDS env). Nilai ambang minimum mengikuti cadence tidak bisa lebih rendah.
</p>
{mpMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>} {mpMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
</section> </section>

View File

@@ -5,8 +5,8 @@
env: env:
# App identifiers # App identifiers
APP_ID_ANDROID: com.halobestie.mitra.mitra_app APP_ID_ANDROID: com.mybestie.mitra
APP_ID_IOS: com.halobestie.mitra APP_ID_IOS: com.mybestie.mitra
# Backend the app talks to — must match what the installed APK was built with. # Backend the app talks to — must match what the installed APK was built with.
BACKEND_URL: http://192.168.88.247:3000 BACKEND_URL: http://192.168.88.247:3000

View File

@@ -4,11 +4,15 @@
# Run: # Run:
# maestro test mitra_app/.maestro/flows/01_smoke.yaml # maestro test mitra_app/.maestro/flows/01_smoke.yaml
# #
# Pre-req: mitra_app debug APK installed on the connected device, signed in as a mitra. # Pre-req: mitra_app debug APK installed on the connected device, signed in
# as a mitra. Stage 2 removed the "Sesi Aktif" / "Riwayat Chat" tiles — the
# stable marker on the new BestieHome is the status card "Kamu lagi
# ONLINE" / "Kamu lagi OFFLINE". Either is fine for the smoke check.
appId: ${APP_ID_ANDROID} appId: ${APP_ID_ANDROID}
--- ---
- launchApp: - launchApp:
clearState: false clearState: false
- assertVisible: - extendedWaitUntil:
text: "Sesi Aktif|Riwayat Chat" visible:
text: "(?s).*Kamu lagi (ONLINE|OFFLINE).*"
timeout: 10000 timeout: 10000

View File

@@ -1,6 +1,16 @@
# Verifies the online/offline toggle works and reflects in the UI. # Verifies the online/offline toggle works and reflects in the UI.
# This is independent of the customer side — pure mitra UI test. # This is independent of the customer side — pure mitra UI test.
# #
# Stage 2 replaced the Switch widget with a single "Ganti Status" CTA on
# the online variant (and "Nyalain Status (Online)" on the offline variant).
# The status card copy ("Kamu lagi ONLINE" / "Kamu lagi OFFLINE") is the
# stable marker for the current state.
#
# A more thorough version of this test is now in
# ts-mitra-1-03-toggle_online_to_offline.yaml — that one walks the full
# auth flow and screenshots both states. This file keeps the lightweight
# variant for fast smoke iteration on a pre-signed-in device.
#
# Run: # Run:
# maestro test mitra_app/.maestro/flows/02_online_offline_toggle.yaml # maestro test mitra_app/.maestro/flows/02_online_offline_toggle.yaml
appId: ${APP_ID_ANDROID} appId: ${APP_ID_ANDROID}
@@ -8,16 +18,17 @@ appId: ${APP_ID_ANDROID}
- launchApp: - launchApp:
clearState: false clearState: false
# Find the toggle and capture initial state. # Establish baseline — exactly one of the two status-card labels is up.
- assertVisible: - assertVisible:
text: "Online|Offline" text: "(?s).*Kamu lagi (ONLINE|OFFLINE).*"
# Tap the toggle — it's a Switch widget; Maestro can tap by adjacent text label. # Tap whichever CTA is currently rendered. Online → "Ganti Status".
- tapOn: # Offline → "Nyalain Status (Online)". The regex matches both.
text: "Online|Offline" - tapOn: "(?s).*(Ganti Status|Nyalain Status \\(Online\\)).*"
# After flipping, the opposite label should appear within ~2s # After the POST /api/mitra/status/{online,offline} response lands, the
# (status is server-confirmed via /api/mitra/status/online or /offline). # opposite status label should be visible within ~2s.
- assertVisible: - extendedWaitUntil:
text: "Online|Offline" visible:
text: "(?s).*Kamu lagi (ONLINE|OFFLINE).*"
timeout: 5000 timeout: 5000

View File

@@ -8,26 +8,41 @@
# 3. The customer has an existing confirmed payment_session ready to blast (use the # 3. The customer has an existing confirmed payment_session ready to blast (use the
# seed_customer_pending_blast.sh helper) # seed_customer_pending_blast.sh helper)
# #
# A more thorough version that walks auth + asserts every popup element is in
# ts-mitra-3-01-incoming_popup_curhat_baru.yaml; this file keeps the
# lightweight smoke version for fast iteration on a pre-signed-in device.
#
# Run: # Run:
# maestro test mitra_app/.maestro/flows/03_accept_general_blast.yaml # maestro test mitra_app/.maestro/flows/03_accept_general_blast.yaml
appId: ${APP_ID_ANDROID} appId: ${APP_ID_ANDROID}
--- ---
- launchApp: - launchApp:
clearState: false clearState: false
- assertVisible: "Online" # ensure mitra is online before triggering the blast
# Ensure mitra is online before triggering the blast. Stage 2 swapped the
# Switch widget for a "Kamu lagi ONLINE" status card.
- assertVisible:
text: "(?s).*Kamu lagi ONLINE.*"
# Step 1: simulate a customer creating a confirmed payment + firing a general blast. # Step 1: simulate a customer creating a confirmed payment + firing a general blast.
# This script returns once the blast notification has been sent to this mitra. # This script returns once the blast notification has been sent to this mitra.
- runScript: ../scripts/customer_blast_now.sh - runScript: ../scripts/customer_blast_now.sh
# Step 2: incoming-request overlay appears on this device # Step 2: incoming-request popup appears on this device (BestieIncomingPopup,
- assertVisible: # variant=new — pink-bordered card with "Curhat Baru!" headline).
text: "Terima" - extendedWaitUntil:
visible:
text: "(?s).*Curhat Baru!.*"
timeout: 10000 timeout: 10000
- assertVisible: "Tolak"
# Step 3: mitra accepts → overlay closes, chat opens
- tapOn: "Terima"
- assertVisible: - assertVisible:
text: "Sesi Aktif" text: "(?s).*Terima.*"
timeout: 5000 - assertVisible:
text: "(?s).*Tolak.*"
# Step 3: mitra accepts → popup closes, chat opens. BestieChatV5 active
# subtitle is "sesi aktif · Chat".
- tapOn: "(?s).*Terima.*"
- extendedWaitUntil:
visible:
text: "(?s).*sesi aktif · Chat.*"
timeout: 10000

View File

@@ -12,7 +12,7 @@ Tests use the naming convention `ts-mitra-<section>-<sub>-<description>.yaml`:
| File | Branch (spec ref) | Expected destination | | File | Branch (spec ref) | Expected destination |
|---|---|---| |---|---|---|
| `ts-mitra-A-01-phone_invalid_inline_error.yaml` | §A.1 local CTA gate for short phones | "kirim kode" disabled for <9 subscriber digits, enables + navigates on valid input | | `ts-mitra-A-01-phone_invalid_inline_error.yaml` | §A.1 local CTA gate for short phones | "kirim kode" disabled for <9 subscriber digits, enables + navigates on valid input |
| `ts-mitra-A-03-success_login_to_home.yaml` | §A.2 200 + `is_active=true` | `/home` (active sessions tab) | | `ts-mitra-A-03-success_login_to_home.yaml` | §A.2 200 + `is_active=true` | `/home` (BestieHome online; asserts "Kamu lagi ONLINE" — Stage 2 removed the Sesi Aktif / Riwayat Chat tiles) |
| `ts-mitra-A-04-account_inactive_screen.yaml` | §A.2 200 + `is_active=false` → 403 `ACCOUNT_INACTIVE` | `/auth/inactive` (AppBar back arrow omitted; system-back interception deferred) | | `ts-mitra-A-04-account_inactive_screen.yaml` | §A.2 200 + `is_active=false` → 403 `ACCOUNT_INACTIVE` | `/auth/inactive` (AppBar back arrow omitted; system-back interception deferred) |
| `ts-mitra-A-05-phone_format_variants.yaml` | §A.1 phone-format normalization | 5 variants (`8…`, `08…`, `628…`, `+628…`, `0628…`) all reach S3b with `+628200000501` shown | | `ts-mitra-A-05-phone_format_variants.yaml` | §A.1 phone-format normalization | 5 variants (`8…`, `08…`, `628…`, `+628…`, `0628…`) all reach S3b with `+628200000501` shown |
| `ts-mitra-A-06-back_to_login_and_retry.yaml` | §A.1 regression — back from S3b + re-submit | Second "kirim kode" tap still navigates to S3b after returning to S3a | | `ts-mitra-A-06-back_to_login_and_retry.yaml` | §A.1 regression — back from S3b + re-submit | Second "kirim kode" tap still navigates to S3b after returning to S3a |
@@ -39,6 +39,10 @@ interference:
- A-04 → `+628200000401` - A-04 → `+628200000401`
- A-05 → `+628200000501` (one phone, 5 input formats) - A-05 → `+628200000501` (one phone, 5 input formats)
- A-06 → `+628200000601` - A-06 → `+628200000601`
- §1 Home (ts-mitra-1-*) → `+62820000070{1..3}`
- §2 Undangan (ts-mitra-2-*) → `+62820000080{1..2}` (2-03 piggybacks on a
pre-signed-in device, no fresh OTP)
- §3 Popup + Chat (ts-mitra-3-*) → `+62820000090{1..4}`
If the same phone gets used across multiple flows in one run, the per-IP If the same phone gets used across multiple flows in one run, the per-IP
rate-limit (10 OTP requests / hour, default) can trip and break A-03 or A-04 rate-limit (10 OTP requests / hour, default) can trip and break A-03 or A-04

View File

@@ -0,0 +1,84 @@
# ts-mitra-1-01 — §1 Bestie Home (online variant) renders end-to-end
# Spec ref: requirement/flow_mitra.mermaid.md §1 + figma BestieHome (v4.jsx:417)
#
# Walks: seed active mitra → reset OTP → S3a → S3b → /home → assert online
# variant chrome (greeting, tiles, status card, Ganti Status CTA, Pengingat,
# BestieTabBar). Screenshot at the end so this also serves as a design-review
# evidence baseline for Stage 7.
#
# A successful login lands on /home with the mitra already ONLINE — the
# status_notifier's load() seeds StatusLoadedData(isOnline:true) for any
# mitra that's been online in this dev DB, and the maestro test mitras are
# left online by prior runs. To make this test deterministic regardless of
# prior state, we reset-all-mitras-online before launching.
appId: com.mybestie.mitra
env:
TEST_PHONE: "+628200000701"
MITRA_DISPLAY_NAME: "Maestro Home Online"
BACKEND_INTERNAL_URL: http://localhost:3001
---
- runScript:
file: ../scripts/seed_mitra.js
env:
TEST_PHONE: ${TEST_PHONE}
MITRA_DISPLAY_NAME: ${MITRA_DISPLAY_NAME}
IS_ACTIVE: "true"
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- runScript:
file: ../scripts/reset_phone.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- launchApp:
clearState: true
- extendedWaitUntil:
visible:
text: "(?s).*Halo Mitra Bestie.*"
timeout: 10000
# S3a — request OTP.
- tapOn:
point: "50%, 53%"
- inputText: "8200000701"
- tapOn: "(?s).*kirim kode.*"
- extendedWaitUntil:
visible:
text: "(?s).*masukin 6 digit kode.*"
timeout: 10000
# Peek + submit correct code.
- runScript:
file: ../scripts/peek_otp.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- inputText: ${output.OTP}
# Home renders — wait for the greeting then assert the rest of the chrome.
- extendedWaitUntil:
visible:
text: "(?s).*Bestie Maestro Home Online.*"
timeout: 15000
- assertVisible:
text: "(?s).*Kamu lagi (ONLINE|OFFLINE).*"
- assertVisible:
text: "(?s).*Undangan.*"
- assertVisible:
text: "(?s).*Perpanjang.*"
- assertVisible:
text: "(?s).*(Ganti Status|Nyalain Status).*"
- assertVisible:
text: "(?s).*Pengingat.*"
- assertVisible:
text: "(?s).*Opening protocol.*"
# BestieTabBar: Home / Chat / Profil
- assertVisible:
text: "(?s).*Home.*"
- assertVisible:
text: "(?s).*Chat.*"
- assertVisible:
text: "(?s).*Profil.*"
- takeScreenshot: ts-mitra-1-01-home-online

View File

@@ -0,0 +1,77 @@
# ts-mitra-1-01a — §1 Home after login, SCENARIO 1: freshly created mitra
# with NO mitra_online_status row → home renders OFFLINE.
#
# Spec ref: requirement/flow_mitra.mermaid.md §1
# DB invariant: mitras row exists; mitra_online_status row absent.
# Expected behavior: app's first GET /api/mitra/status creates a row via
# ensureStatusRow() with DB default is_online=false → BestieHomeOffline.
#
# Setup: seed_mitra creates the mitra row. delete_mitra_status_row removes
# any pre-existing online_status row so this run starts from the true
# "freshly created" state (even if a prior test run had touched the row).
appId: com.mybestie.mitra
env:
TEST_PHONE: "+628200000711"
MITRA_DISPLAY_NAME: "Maestro Fresh User"
BACKEND_INTERNAL_URL: http://localhost:3001
---
- runScript:
file: ../scripts/seed_mitra.js
env:
TEST_PHONE: ${TEST_PHONE}
MITRA_DISPLAY_NAME: ${MITRA_DISPLAY_NAME}
IS_ACTIVE: "true"
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- runScript:
file: ../scripts/reset_phone.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
# Remove any pre-existing mitra_online_status row so the precondition is
# precisely "freshly created mitra, no status row". The app's status call
# will recreate the row with default is_online=false.
- runScript:
file: ../scripts/delete_mitra_status_row.js
env:
MITRA_ID: ${output.MITRA_ID}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- launchApp:
clearState: true
- extendedWaitUntil:
visible:
text: "(?s).*Halo Mitra Bestie.*"
timeout: 15000
# S3a → S3b → /home
- tapOn:
point: "50%, 53%"
- inputText: "8200000711"
- tapOn: "(?s).*kirim kode.*"
- extendedWaitUntil:
visible:
text: "(?s).*masukin 6 digit kode.*"
timeout: 10000
- runScript:
file: ../scripts/peek_otp.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- inputText: ${output.OTP}
# Verify: fresh user lands on BestieHomeOffline.
- extendedWaitUntil:
visible:
text: "(?s).*Bestie Maestro Fresh User.*"
timeout: 15000
- assertVisible:
text: "(?s).*Kamu lagi OFFLINE.*"
- assertVisible:
text: "(?s).*🌙.*"
- assertVisible:
text: "(?s).*Nyalain Status \\(Online\\).*"
# Negative: should NOT render the online variant chrome.
- assertNotVisible: "(?s).*Kamu lagi ONLINE.*"
- assertNotVisible: "(?s).*Pengingat.*"
- takeScreenshot: ts-mitra-1-01a-fresh-user-offline

View File

@@ -0,0 +1,77 @@
# ts-mitra-1-01b — §1 Home after login, SCENARIO 2: existing mitra who was
# OFFLINE before logout → relogin shows OFFLINE.
#
# Spec ref: requirement/flow_mitra.mermaid.md §1
# DB invariant: mitras row exists; mitra_online_status row exists with
# is_online=false (the post-logout state of someone who toggled offline
# before signing out).
# Expected behavior: app's GET /api/mitra/status returns is_online=false →
# BestieHomeOffline.
#
# Setup: seed_mitra + force_mitra_offline simulates the post-logout state
# of an existing user who was offline.
#
# This is functionally identical to ts-mitra-1-02 but tracks the SCENARIO 2
# slot explicitly. Different phone slot so it doesn't collide.
appId: com.mybestie.mitra
env:
TEST_PHONE: "+628200000712"
MITRA_DISPLAY_NAME: "Maestro Existing Offline"
BACKEND_INTERNAL_URL: http://localhost:3001
---
- runScript:
file: ../scripts/seed_mitra.js
env:
TEST_PHONE: ${TEST_PHONE}
MITRA_DISPLAY_NAME: ${MITRA_DISPLAY_NAME}
IS_ACTIVE: "true"
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- runScript:
file: ../scripts/reset_phone.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
# Force OFFLINE — simulates someone who toggled off before logout.
- runScript:
file: ../scripts/force_mitra_offline.js
env:
MITRA_ID: ${output.MITRA_ID}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- launchApp:
clearState: true
- extendedWaitUntil:
visible:
text: "(?s).*Halo Mitra Bestie.*"
timeout: 15000
# S3a → S3b → /home
- tapOn:
point: "50%, 53%"
- inputText: "8200000712"
- tapOn: "(?s).*kirim kode.*"
- extendedWaitUntil:
visible:
text: "(?s).*masukin 6 digit kode.*"
timeout: 10000
- runScript:
file: ../scripts/peek_otp.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- inputText: ${output.OTP}
# Verify: relogin lands on BestieHomeOffline (still offline from pre-logout).
- extendedWaitUntil:
visible:
text: "(?s).*Bestie Maestro Existing Offline.*"
timeout: 15000
- assertVisible:
text: "(?s).*Kamu lagi OFFLINE.*"
- assertVisible:
text: "(?s).*🌙.*"
- assertVisible:
text: "(?s).*Nyalain Status \\(Online\\).*"
- assertNotVisible: "(?s).*Kamu lagi ONLINE.*"
- takeScreenshot: ts-mitra-1-01b-existing-offline-relogin

View File

@@ -0,0 +1,82 @@
# ts-mitra-1-01c — §1 Home after login, SCENARIO 3: existing mitra who was
# ONLINE before logout → relogin shows ONLINE.
#
# Spec ref: requirement/flow_mitra.mermaid.md §1
# DB invariant: mitras row exists; mitra_online_status row exists with
# is_online=true (the post-logout state of someone who stayed online before
# signing out, or whose ONLINE state was preserved across sessions).
# Expected behavior: app's GET /api/mitra/status returns is_online=true →
# BestieHome (online variant): 🌸 greeting, tile grid, ONLINE status card,
# Ganti Status CTA, Pengingat.
#
# Setup: seed_mitra + force_mitra_online makes the existing user ONLINE,
# then login should reflect that state.
appId: com.mybestie.mitra
env:
TEST_PHONE: "+628200000713"
MITRA_DISPLAY_NAME: "Maestro Existing Online"
BACKEND_INTERNAL_URL: http://localhost:3001
---
- runScript:
file: ../scripts/seed_mitra.js
env:
TEST_PHONE: ${TEST_PHONE}
MITRA_DISPLAY_NAME: ${MITRA_DISPLAY_NAME}
IS_ACTIVE: "true"
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- runScript:
file: ../scripts/reset_phone.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
# Force ONLINE — simulates someone who was online at logout time.
- runScript:
file: ../scripts/force_mitra_online.js
env:
MITRA_ID: ${output.MITRA_ID}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- launchApp:
clearState: true
- extendedWaitUntil:
visible:
text: "(?s).*Halo Mitra Bestie.*"
timeout: 15000
# S3a → S3b → /home
- tapOn:
point: "50%, 53%"
- inputText: "8200000713"
- tapOn: "(?s).*kirim kode.*"
- extendedWaitUntil:
visible:
text: "(?s).*masukin 6 digit kode.*"
timeout: 10000
- runScript:
file: ../scripts/peek_otp.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- inputText: ${output.OTP}
# Verify: relogin lands on BestieHome (online variant — still online from pre-logout).
- extendedWaitUntil:
visible:
text: "(?s).*Bestie Maestro Existing Online.*"
timeout: 15000
- assertVisible:
text: "(?s).*Kamu lagi ONLINE.*"
- assertVisible:
text: "(?s).*🌸.*"
- assertVisible:
text: "(?s).*Ganti Status.*"
# Online variant has the tile grid + Pengingat.
- assertVisible:
text: "(?s).*Undangan.*"
- assertVisible:
text: "(?s).*Perpanjang.*"
- assertVisible:
text: "(?s).*Pengingat.*"
- assertNotVisible: "(?s).*Kamu lagi OFFLINE.*"
- takeScreenshot: ts-mitra-1-01c-existing-online-relogin

View File

@@ -0,0 +1,74 @@
# ts-mitra-1-02 — §1 Bestie Home (offline variant) renders
# Spec ref: requirement/flow_mitra.mermaid.md §1 + figma BestieHomeOffline (v5.jsx:188)
#
# Same auth as 1-01 but the mitra is forced OFFLINE in the DB before the
# app launches. The status_notifier's GET /api/mitra/status returns
# is_online=false on load → the home renders the offline variant: 🌙
# greeting, 😴 OFFLINE card, "Nyalain Status (Online)" CTA, and crucially
# NO tiles / NO Pengingat (those are online-only chrome).
appId: com.mybestie.mitra
env:
TEST_PHONE: "+628200000702"
MITRA_DISPLAY_NAME: "Maestro Home Offline"
BACKEND_INTERNAL_URL: http://localhost:3001
---
- runScript:
file: ../scripts/seed_mitra.js
env:
TEST_PHONE: ${TEST_PHONE}
MITRA_DISPLAY_NAME: ${MITRA_DISPLAY_NAME}
IS_ACTIVE: "true"
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- runScript:
file: ../scripts/reset_phone.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
# Force this mitra OFFLINE so the GET /api/mitra/status that fires on home
# mount returns is_online=false. seed_mitra.js exposed MITRA_ID for us.
- runScript:
file: ../scripts/force_mitra_offline.js
env:
MITRA_ID: ${output.MITRA_ID}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- launchApp:
clearState: true
- extendedWaitUntil:
visible:
text: "(?s).*Halo Mitra Bestie.*"
timeout: 10000
# S3a → S3b → /home
- tapOn:
point: "50%, 53%"
- inputText: "8200000702"
- tapOn: "(?s).*kirim kode.*"
- extendedWaitUntil:
visible:
text: "(?s).*masukin 6 digit kode.*"
timeout: 10000
- runScript:
file: ../scripts/peek_otp.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- inputText: ${output.OTP}
# Offline variant chrome
- extendedWaitUntil:
visible:
text: "(?s).*Bestie Maestro Home Offline.*"
timeout: 15000
# The header greeting suffix flips to 🌙 in the offline variant.
- assertVisible:
text: "(?s).*🌙.*"
- assertVisible:
text: "(?s).*Kamu lagi OFFLINE.*"
- assertVisible:
text: "(?s).*Nyalain Status \\(Online\\).*"
# Negative assertions: tiles and Pengingat are online-only chrome.
- assertNotVisible: "(?s).*Pengingat.*"
- assertNotVisible: "(?s).*Opening protocol.*"
- takeScreenshot: ts-mitra-1-02-home-offline

View File

@@ -0,0 +1,77 @@
# ts-mitra-1-03 — §1 Ganti Status toggles online ⇄ offline UI
# Spec ref: requirement/flow_mitra.mermaid.md §1
#
# Starts on /home in the online variant, taps "Ganti Status" → asserts the
# offline variant takes over, taps "Nyalain Status (Online)" → asserts the
# online variant returns. Screenshots at both states for the design review.
#
# Online toggle posts /api/mitra/status/online (offline → /offline). The
# status_notifier sets StatusLoadedData immediately on success so the UI
# flips within ~1 frame after the response.
appId: com.mybestie.mitra
env:
TEST_PHONE: "+628200000703"
MITRA_DISPLAY_NAME: "Maestro Toggle"
BACKEND_INTERNAL_URL: http://localhost:3001
---
- runScript:
file: ../scripts/seed_mitra.js
env:
TEST_PHONE: ${TEST_PHONE}
MITRA_DISPLAY_NAME: ${MITRA_DISPLAY_NAME}
IS_ACTIVE: "true"
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- runScript:
file: ../scripts/reset_phone.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- launchApp:
clearState: true
- extendedWaitUntil:
visible:
text: "(?s).*Halo Mitra Bestie.*"
timeout: 10000
- tapOn:
point: "50%, 53%"
- inputText: "8200000703"
- tapOn: "(?s).*kirim kode.*"
- extendedWaitUntil:
visible:
text: "(?s).*masukin 6 digit kode.*"
timeout: 10000
- runScript:
file: ../scripts/peek_otp.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- inputText: ${output.OTP}
# Online variant on first land.
- extendedWaitUntil:
visible:
text: "(?s).*Kamu lagi (ONLINE|OFFLINE).*"
timeout: 15000
- takeScreenshot: ts-mitra-1-03-online-before-toggle
# Tap Ganti Status → flip to offline.
- tapOn: "(?s).*(Ganti Status|Nyalain Status).*"
- extendedWaitUntil:
visible:
text: "(?s).*Kamu lagi OFFLINE.*"
timeout: 10000
- assertVisible:
text: "(?s).*Nyalain Status \\(Online\\).*"
- takeScreenshot: ts-mitra-1-03-offline-after-toggle
# Tap Nyalain Status → flip back to online.
- tapOn: "(?s).*Nyalain Status \\(Online\\).*"
- extendedWaitUntil:
visible:
text: "(?s).*Kamu lagi (ONLINE|OFFLINE).*"
timeout: 10000
- assertVisible:
text: "(?s).*(Ganti Status|Nyalain Status).*"
- takeScreenshot: ts-mitra-1-03-online-after-second-toggle

View File

@@ -0,0 +1,92 @@
# ts-mitra-1-04 — §1 Home Undangan tile → Chat tab (Curhat Baru sub-tab)
# Spec ref: requirement/flow_mitra.mermaid.md §1
#
# The Undangan tile on BestieHome (home_screen.dart L200-212) writes 0 to
# undanganTabProvider then calls shell.goBranch(1) which routes to the Chat
# branch (Undangan). UndanganScreen reads the provider on init and selects
# the Curhat Baru tab (index 0).
#
# Walks: login → home online → tap Undangan tile → assert we landed on the
# Chat branch with Curhat Baru as the active sub-tab (empty-state copy
# proves we're on the right sub-tab).
appId: com.mybestie.mitra
env:
TEST_PHONE: "+628200000704"
MITRA_DISPLAY_NAME: "Maestro Undangan Tile"
BACKEND_INTERNAL_URL: http://localhost:3001
---
- runScript:
file: ../scripts/seed_mitra.js
env:
TEST_PHONE: ${TEST_PHONE}
MITRA_DISPLAY_NAME: ${MITRA_DISPLAY_NAME}
IS_ACTIVE: "true"
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- runScript:
file: ../scripts/reset_phone.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- launchApp:
clearState: true
- extendedWaitUntil:
visible:
text: "(?s).*Halo Mitra Bestie.*"
timeout: 10000
- tapOn:
point: "50%, 53%"
- inputText: "8200000704"
- tapOn: "(?s).*kirim kode.*"
- extendedWaitUntil:
visible:
text: "(?s).*masukin 6 digit kode.*"
timeout: 10000
- runScript:
file: ../scripts/peek_otp.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- inputText: ${output.OTP}
- extendedWaitUntil:
visible:
text: "(?s).*Kamu lagi (ONLINE|OFFLINE).*"
timeout: 15000
# Conditional online toggle. Fresh mitra defaults to OFFLINE (per DB schema
# default for mitra_online_status.is_online). If a prior test run force-onlined
# this row already, skip the CTA. Otherwise tap "Nyalain Status (Online)" so
# downstream flow has the tile grid + is blast-eligible.
- runFlow:
when:
visible:
text: "(?s).*Kamu lagi OFFLINE.*"
commands:
- tapOn: "(?s).*Nyalain Status.*"
- extendedWaitUntil:
visible:
text: "(?s).*Kamu lagi ONLINE.*"
timeout: 10000
# Tap the Undangan tile (online-only chrome — tile grid is hidden when offline).
# Use the tile label "Undangan" rather than the icon (emoji selectors are flaky
# across renderers). The tile is the topmost match for "Undangan" since the
# tab bar label hasn't rendered yet on the Home screen.
- tapOn: "(?s).*Undangan.*"
# Curhat Baru sub-tab visible + empty-state copy (no pending invites in this
# fresh DB row). Both tabs labels are visible — verify Curhat Baru is the
# active one by asserting on the empty-state copy (which only appears under
# the Curhat Baru tab, not under Perpanjang).
- extendedWaitUntil:
visible:
text: "(?s).*Curhat Baru.*"
timeout: 8000
- assertVisible:
text: "(?s).*Perpanjang Curhat.*"
- assertVisible:
text: "(?s).*Belum ada undangan masuk.*"
- takeScreenshot: ts-mitra-1-04-curhat-baru-from-tile

View File

@@ -0,0 +1,84 @@
# ts-mitra-1-05 — §1 Home Perpanjang tile → Chat tab (Perpanjang sub-tab)
# Spec ref: requirement/flow_mitra.mermaid.md §1
#
# The Perpanjang tile (home_screen.dart L220-231) writes 1 to
# undanganTabProvider then calls shell.goBranch(1). UndanganScreen picks
# Perpanjang Curhat (index 1) on init.
#
# Walks: login → home online → tap Perpanjang tile → assert Perpanjang sub-
# tab is active (empty-state placeholder copy from _PerpanjangTab visible).
appId: com.mybestie.mitra
env:
TEST_PHONE: "+628200000705"
MITRA_DISPLAY_NAME: "Maestro Perpanjang Tile"
BACKEND_INTERNAL_URL: http://localhost:3001
---
- runScript:
file: ../scripts/seed_mitra.js
env:
TEST_PHONE: ${TEST_PHONE}
MITRA_DISPLAY_NAME: ${MITRA_DISPLAY_NAME}
IS_ACTIVE: "true"
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- runScript:
file: ../scripts/reset_phone.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- launchApp:
clearState: true
- extendedWaitUntil:
visible:
text: "(?s).*Halo Mitra Bestie.*"
timeout: 10000
- tapOn:
point: "50%, 53%"
- inputText: "8200000705"
- tapOn: "(?s).*kirim kode.*"
- extendedWaitUntil:
visible:
text: "(?s).*masukin 6 digit kode.*"
timeout: 10000
- runScript:
file: ../scripts/peek_otp.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- inputText: ${output.OTP}
- extendedWaitUntil:
visible:
text: "(?s).*Kamu lagi (ONLINE|OFFLINE).*"
timeout: 15000
# Conditional online toggle. Fresh mitra defaults to OFFLINE (per DB schema
# default for mitra_online_status.is_online). If a prior test run force-onlined
# this row already, skip the CTA. Otherwise tap "Nyalain Status (Online)" so
# downstream flow has the tile grid + is blast-eligible.
- runFlow:
when:
visible:
text: "(?s).*Kamu lagi OFFLINE.*"
commands:
- tapOn: "(?s).*Nyalain Status.*"
- extendedWaitUntil:
visible:
text: "(?s).*Kamu lagi ONLINE.*"
timeout: 10000
# Tap the Perpanjang tile.
- tapOn: "(?s).*Perpanjang.*"
# Perpanjang sub-tab active → placeholder empty-state copy visible.
- extendedWaitUntil:
visible:
text: "(?s).*Belum ada permintaan perpanjangan.*"
timeout: 8000
- assertVisible:
text: "(?s).*Curhat Baru.*"
- assertVisible:
text: "(?s).*Perpanjang Curhat.*"
- takeScreenshot: ts-mitra-1-05-perpanjang-from-tile

View File

@@ -0,0 +1,77 @@
# ts-mitra-1-06 — §1 Offline variant: tile grid (Undangan/Perpanjang) is hidden
# Spec ref: requirement/flow_mitra.mermaid.md §1 (offline branch)
#
# When the mitra is OFFLINE the home renders BestieHomeOffline which (per
# Stage 2) drops the entire tile grid + Pengingat — only the greeting,
# status card, and "Nyalain Status (Online)" CTA remain.
#
# Negative coverage: tile labels "Undangan" / "Perpanjang" are NOT visible
# on Home in the offline state. Complements ts-mitra-1-02 (which is
# focused on the offline chrome) with a tile-grid-specific negative assertion.
appId: com.mybestie.mitra
env:
TEST_PHONE: "+628200000706"
MITRA_DISPLAY_NAME: "Maestro Tiles Hidden"
BACKEND_INTERNAL_URL: http://localhost:3001
---
- runScript:
file: ../scripts/seed_mitra.js
env:
TEST_PHONE: ${TEST_PHONE}
MITRA_DISPLAY_NAME: ${MITRA_DISPLAY_NAME}
IS_ACTIVE: "true"
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- runScript:
file: ../scripts/reset_phone.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
# Force this mitra OFFLINE so the GET /api/mitra/status on home mount
# returns is_online=false → BestieHomeOffline variant renders without the
# tile grid.
- runScript:
file: ../scripts/force_mitra_offline.js
env:
MITRA_ID: ${output.MITRA_ID}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- launchApp:
clearState: true
- extendedWaitUntil:
visible:
text: "(?s).*Halo Mitra Bestie.*"
timeout: 30000
- tapOn:
point: "50%, 53%"
- inputText: "8200000706"
- tapOn: "(?s).*kirim kode.*"
- extendedWaitUntil:
visible:
text: "(?s).*masukin 6 digit kode.*"
timeout: 10000
- runScript:
file: ../scripts/peek_otp.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- inputText: ${output.OTP}
# Offline variant lands.
- extendedWaitUntil:
visible:
text: "(?s).*Kamu lagi OFFLINE.*"
timeout: 15000
- assertVisible:
text: "(?s).*Nyalain Status \\(Online\\).*"
# Tile-grid labels MUST NOT be visible — _PrimaryTileRow is not rendered.
# Note: the "Chat" tab label in the BestieTabBar is still visible at the
# bottom; the negative assertions target the tile-specific labels
# "Undangan" and "Perpanjang" which only appear in the tile-grid widgets.
- assertNotVisible: "(?s).*Undangan.*"
- assertNotVisible: "(?s).*Perpanjang.*"
- assertNotVisible: "(?s).*Pengingat.*"
- assertNotVisible: "(?s).*Opening protocol.*"
- takeScreenshot: ts-mitra-1-06-offline-no-tiles

View File

@@ -0,0 +1,74 @@
# ts-mitra-2-01 — §2 Undangan: Curhat Baru tab empty state
# Spec ref: requirement/flow_mitra.mermaid.md §2 + figma BestieInvites (v4.jsx)
#
# Walks: login → home → tap Chat tab in BestieTabBar → assert Undangan
# screen renders, two tab labels visible, Curhat Baru is the default active
# tab and shows the empty state copy.
#
# This test does NOT fire a customer blast — the goal is the empty-state
# layout. ts-mitra-2-03 covers the populated case.
appId: com.mybestie.mitra
env:
TEST_PHONE: "+628200000801"
MITRA_DISPLAY_NAME: "Maestro Undangan Empty"
BACKEND_INTERNAL_URL: http://localhost:3001
---
- runScript:
file: ../scripts/seed_mitra.js
env:
TEST_PHONE: ${TEST_PHONE}
MITRA_DISPLAY_NAME: ${MITRA_DISPLAY_NAME}
IS_ACTIVE: "true"
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- runScript:
file: ../scripts/reset_phone.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- launchApp:
clearState: true
- extendedWaitUntil:
visible:
text: "(?s).*Halo Mitra Bestie.*"
timeout: 10000
- tapOn:
point: "50%, 53%"
- inputText: "8200000801"
- tapOn: "(?s).*kirim kode.*"
- extendedWaitUntil:
visible:
text: "(?s).*masukin 6 digit kode.*"
timeout: 10000
- runScript:
file: ../scripts/peek_otp.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- inputText: ${output.OTP}
- extendedWaitUntil:
visible:
text: "(?s).*Kamu lagi (ONLINE|OFFLINE).*"
timeout: 15000
# Tap Chat tab in the bottom BestieTabBar. The label text is "Chat" — the
# Home variant doesn't render that string elsewhere on screen so the regex
# match is unique.
- tapOn: "(?s).*Chat.*"
# Undangan screen renders with both tabs visible. Default active tab is
# Curhat Baru → empty state copy is visible.
- extendedWaitUntil:
visible:
text: "(?s).*Undangan.*"
timeout: 8000
- assertVisible:
text: "(?s).*Curhat Baru.*"
- assertVisible:
text: "(?s).*Perpanjang Curhat.*"
- assertVisible:
text: "(?s).*Belum ada undangan masuk.*"
- takeScreenshot: ts-mitra-2-01-curhat-baru-empty

View File

@@ -0,0 +1,69 @@
# ts-mitra-2-02 — §2 Undangan: Perpanjang Curhat tab empty state
# Spec ref: requirement/flow_mitra.mermaid.md §2 + figma BestieInvitesExtend (v5.jsx)
#
# Same as 2-01 but taps into the second tab (Perpanjang Curhat) and asserts
# its dedicated empty-state copy. The Perpanjang tab today is a placeholder
# until the backend exposes a queryable list of pending extension invitations
# (see undangan_screen.dart::_PerpanjangTab TODO), so the empty state is the
# only verifiable visual.
appId: com.mybestie.mitra
env:
TEST_PHONE: "+628200000802"
MITRA_DISPLAY_NAME: "Maestro Perpanjang Empty"
BACKEND_INTERNAL_URL: http://localhost:3001
---
- runScript:
file: ../scripts/seed_mitra.js
env:
TEST_PHONE: ${TEST_PHONE}
MITRA_DISPLAY_NAME: ${MITRA_DISPLAY_NAME}
IS_ACTIVE: "true"
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- runScript:
file: ../scripts/reset_phone.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- launchApp:
clearState: true
- extendedWaitUntil:
visible:
text: "(?s).*Halo Mitra Bestie.*"
timeout: 10000
- tapOn:
point: "50%, 53%"
- inputText: "8200000802"
- tapOn: "(?s).*kirim kode.*"
- extendedWaitUntil:
visible:
text: "(?s).*masukin 6 digit kode.*"
timeout: 10000
- runScript:
file: ../scripts/peek_otp.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- inputText: ${output.OTP}
- extendedWaitUntil:
visible:
text: "(?s).*Kamu lagi (ONLINE|OFFLINE).*"
timeout: 15000
# Navigate to Undangan via the Chat tab.
- tapOn: "(?s).*Chat.*"
- extendedWaitUntil:
visible:
text: "(?s).*Undangan.*"
timeout: 8000
# Switch to the Perpanjang Curhat tab.
- tapOn: "(?s).*Perpanjang Curhat.*"
- extendedWaitUntil:
visible:
text: "(?s).*Belum ada permintaan perpanjangan.*"
timeout: 5000
- takeScreenshot: ts-mitra-2-02-perpanjang-empty

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