Phase 5/6 polish: end-session flow, notif sound on API 33+, Xendit webview

Customer end-of-session (figma §6):
- PricingBottomSheet: ghost "cukup, akhiri sesi" CTA + dedup divider
- chat_screen._runEndSessionFlow chains ConfirmEndStep1 → ConfirmEndStep2
  → ClosingMessageSheet (or "lewati saja" → close + /home). The four
  popup/sheet widgets already existed; this commit just wires them
- showModalBottomSheet: showDragHandle=false to suppress the Material 3
  auto-injected handle that was stacking with our own pill

Notification sound on API 33+:
- Bump channel halobestie_chat_v1 → halobestie_chat_v2, created from
  native Kotlin in MainActivity.kt with AudioAttributes contentType
  CONTENT_TYPE_SONIFICATION. flutter_local_notifications' default of
  CONTENT_TYPE_UNKNOWN was causing Android 13 to silently drop audio
  focus while the notification still posted (isNoisy=true). Both apps
- Backend FCM payload channelId updated to v2
- AndroidManifest meta-data: default_notification_icon + color → brand
  silhouette tinted pink instead of generic Android bell. Both apps

Customer pairing reliability:
- pairing_notifier: applyPairedFromPush({sessionId, mitraName}) unsticks
  searching screen when WS push failed and FCM/active-session-poll is
  the first signal. Idempotent across PairingSearchingData,
  PairingTargetedWaitingData, PairingErrorData (covers ALREADY_ACTIVE)
- notification_service: dispatches every FCM data payload to an
  onDataMessage callback (foreground + tap + cold-start). main.dart
  wires that to applyPairedFromPush on type=='paired'. Foreground
  'paired' no longer renders a local banner — screen self-advances
- main.dart activeSession listener also calls applyPairedFromPush when
  a session appears server-side while pairing is in a waiting state.
  Covers stale ALREADY_ACTIVE recovery without a full page refresh

Auth refresh token race:
- auth_notifier._refreshFromStorage shares a single in-flight Future
  across all callers (Auth.build + 401-retry path). Backend rotates
  refresh tokens, so concurrent callers using the same stored token
  would race → loser 401s → catch wipes flutter_secure_storage → user
  appears logged out after kill+reopen

Polish:
- method_pick_screen: resizeToAvoidBottomInset=false — prevents the
  one-frame overflow when entering with the previous screen's keyboard
  still animating out
- bestie_history: BestieHistoryItem now carries `status` (backend
  already returns it). Removed _rawHistoryProvider that fetched the
  same endpoint just to read status; the two providers could go out
  of sync mid-rebuild and throw RangeError(length) on indexing

Xendit Stage 8 (carried from WIP):
- xendit_checkout_screen: embedded webview hosting Xendit's invoice
  page (intercepts halobestie:// deeplink + return-page URLs for
  deterministic pop)
- waiting_payment_screen: auto-pushes the webview when the backend
  payload includes xendit_invoice_url; spinner card + "Buka ulang
  halaman pembayaran" CTA for the QR-fallback path
- pubspec: webview_flutter ^4.13.0

Maestro infra:
- subflows/onboarding_returning_user: drop the "Mulai" carousel wait
  (splash auto-advances since 2026-05-26); tap phone-field hint
  instead of point; drop hideKeyboard (sends BACK → /home when the
  IME isn't actually up)
- New flow ts-customer-06-01-end_session_via_timeup_sheet: drives
  the full path to the chat-expired banner. Last step blocked by a
  Maestro+Flutter gesture quirk on the perpanjang ElevatedButton
  (raw `adb input tap` works at the same coords). Documented in
  memory; deeplink fixture or manual verify recommended
- ChatExpiredBanner button wrapped with Semantics(identifier:
  'chat_extend_button', button: true, onTap: …) — good hygiene for
  future tests even though it doesn't fix the dadb tap issue

.dev/: tracked wsl_emulator_bridge.ps1 + wsl_tcp_relay.py for
Maestro-on-WSL setup (Windows-side netsh portproxy + WSL-side
loopback relays). Both referenced from existing CLAUDE.md notes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 21:45:46 +08:00
parent 2c95fd040d
commit 3a0cdf5c4e
28 changed files with 1424 additions and 159 deletions

View File

@@ -0,0 +1,169 @@
<#
.SYNOPSIS
Toggle a Windows-side TCP bridge so Maestro (running in WSL) can reach the
Android emulators' adb ports.
.DESCRIPTION
Android emulators on the Windows host bind their adb ports (5555, 5557) to
127.0.0.1 only. Maestro's bundled `dadb` connects directly to that port —
not via the adb server — so `ADB_SERVER_SOCKET` is irrelevant. To run
`maestro test` from inside WSL, we expose those ports on the WSL-facing
interface and let the matching WSL-side relay (.dev/wsl_tcp_relay.py)
bridge 127.0.0.1 in WSL → <gateway> on Windows.
Heads-up: idle `netsh portproxy` entries with no live target have been
reported to occasionally pin CPU on iphlpsvc. Keep these rules OFF when
you're not running Maestro from WSL — that is the whole point of this
script.
.PARAMETER Action
up Add port-proxy entries + firewall rules.
down Remove them.
status Print current portproxy + matching firewall rules.
.PARAMETER ListenAddress
Optional. Defaults to the IPv4 of the `vEthernet (WSL)` adapter — the IP
that WSL2 sees as its default gateway. Override only if your WSL adapter
is named differently.
.PARAMETER Ports
Optional. Defaults to 5554,5555,5556,5557 — the console + adb ports for
emulator-5554 (5554 console, 5555 adb) and emulator-5556 (5556 console,
5557 adb). Maestro / dadb need both: the console port for authentication
and the adb port for the actual protocol.
.EXAMPLE
# From an elevated PowerShell, before `maestro test ...` in WSL:
.\wsl_emulator_bridge.ps1 up
.EXAMPLE
# When done testing — tear it back down to avoid the CPU-pin risk:
.\wsl_emulator_bridge.ps1 down
.EXAMPLE
.\wsl_emulator_bridge.ps1 status
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true, Position = 0)]
[ValidateSet('up', 'down', 'status')]
[string]$Action,
[string]$ListenAddress,
[int[]]$Ports = @(5554, 5555, 5556, 5557)
)
$ErrorActionPreference = 'Stop'
$FirewallRulePrefix = 'WSL adb bridge'
function Assert-Admin {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'This script must be run from an elevated PowerShell (Run as administrator).'
}
}
function Resolve-WslGatewayIp {
param([string]$Override)
if ($Override) { return $Override }
# WSL2 always creates a `vEthernet (WSL)` adapter on the Windows host.
# Its IPv4 address is what WSL2 sees as `default via ...` in `ip route`.
$candidates = Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue |
Where-Object {
$_.InterfaceAlias -like 'vEthernet (WSL*)' -and $_.PrefixOrigin -ne 'WellKnown'
} |
Sort-Object -Property InterfaceMetric
if (-not $candidates) {
throw "Couldn't find a `vEthernet (WSL)` adapter. Pass -ListenAddress explicitly."
}
return $candidates[0].IPAddress
}
function Get-PortProxyEntries {
$out = & netsh interface portproxy show v4tov4 2>&1
if ($LASTEXITCODE -ne 0) {
throw "netsh interface portproxy show v4tov4 failed: $out"
}
return $out
}
function Invoke-PortProxyUp {
param([string]$Addr, [int]$Port)
& netsh interface portproxy add v4tov4 `
listenaddress=$Addr listenport=$Port `
connectaddress=127.0.0.1 connectport=$Port | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "Failed to add portproxy for $Addr`:$Port (exit $LASTEXITCODE)"
}
$ruleName = "$FirewallRulePrefix $Port"
$existing = Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue
if (-not $existing) {
New-NetFirewallRule -DisplayName $ruleName `
-Direction Inbound -Action Allow -Protocol TCP `
-LocalPort $Port -LocalAddress $Addr | Out-Null
}
Write-Host " + $Addr`:$Port -> 127.0.0.1:$Port (firewall: $ruleName)" -ForegroundColor Green
}
function Invoke-PortProxyDown {
param([string]$Addr, [int]$Port)
& netsh interface portproxy delete v4tov4 `
listenaddress=$Addr listenport=$Port 2>$null | Out-Null
$ruleName = "$FirewallRulePrefix $Port"
$rule = Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue
if ($rule) { Remove-NetFirewallRule -DisplayName $ruleName }
Write-Host " - $Addr`:$Port removed" -ForegroundColor Yellow
}
switch ($Action) {
'up' {
Assert-Admin
$addr = Resolve-WslGatewayIp -Override $ListenAddress
Write-Host "Bringing WSL adb bridge UP on $addr ..." -ForegroundColor Cyan
foreach ($p in $Ports) { Invoke-PortProxyUp -Addr $addr -Port $p }
Write-Host ''
Write-Host 'Remember to take it DOWN when you are done:' -ForegroundColor DarkGray
Write-Host ' .\wsl_emulator_bridge.ps1 down' -ForegroundColor DarkGray
}
'down' {
Assert-Admin
# If user didn't pass -ListenAddress, try to clean up whichever address
# is currently bound. Walk the existing portproxy entries.
$entries = Get-PortProxyEntries
$matched = @()
foreach ($p in $Ports) {
$regex = "^\s*(\d+\.\d+\.\d+\.\d+)\s+$p\s+"
foreach ($line in $entries) {
if ($line -match $regex) {
$matched += [pscustomobject]@{ Addr = $matches[1]; Port = $p }
}
}
}
if (-not $matched -and $ListenAddress) {
foreach ($p in $Ports) {
$matched += [pscustomobject]@{ Addr = $ListenAddress; Port = $p }
}
}
if (-not $matched) {
Write-Host 'No matching portproxy entries found. Nothing to remove.' -ForegroundColor DarkGray
break
}
Write-Host 'Tearing WSL adb bridge DOWN ...' -ForegroundColor Cyan
foreach ($m in $matched) { Invoke-PortProxyDown -Addr $m.Addr -Port $m.Port }
}
'status' {
Write-Host 'Active portproxy entries:' -ForegroundColor Cyan
Get-PortProxyEntries | ForEach-Object { Write-Host " $_" }
Write-Host ''
Write-Host 'Matching firewall rules:' -ForegroundColor Cyan
Get-NetFirewallRule -DisplayName "$FirewallRulePrefix *" -ErrorAction SilentlyContinue |
Select-Object DisplayName, Enabled, Direction, Action |
Format-Table -AutoSize | Out-String | Write-Host
}
}

134
.dev/wsl_tcp_relay.py Normal file
View File

@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""WSL→Windows TCP relay for the Windows-side adb server.
Two modes:
static: wsl_tcp_relay.py <listen-port> <target-host> <target-port>
Relays 127.0.0.1:<listen-port> -> <target-host>:<target-port>.
dynamic: wsl_tcp_relay.py --watch-adb <target-host>
Polls `adb forward --list` (using the env-configured adb server),
and for each host-side TCP port in the listing, ensures a relay
127.0.0.1:<port> -> <target-host>:<port> exists. Adds/removes
relays as forwards appear/disappear.
The dynamic mode handles `flutter run` choosing an ephemeral host port (and
sometimes rotating it across reconnect attempts) when adb is shared via
ADB_SERVER_SOCKET — flutter's adb-forward request lands on the Windows adb
server, which binds the listener on 0.0.0.0 of the Windows side. WSL-loopback
has nothing on that port, so flutter (connecting to 127.0.0.1:<port> on WSL)
fails. This relay fills that gap dynamically.
"""
from __future__ import annotations
import asyncio
import os
import sys
from typing import Dict, Set
async def _pipe(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
try:
while data := await reader.read(65536):
writer.write(data)
await writer.drain()
except (ConnectionResetError, BrokenPipeError, OSError):
pass
finally:
try:
writer.close()
await writer.wait_closed()
except Exception:
pass
def _handler(target_host: str, target_port: int):
async def handle(local_r, local_w):
try:
remote_r, remote_w = await asyncio.open_connection(target_host, target_port)
except Exception as exc:
print(f"[relay] connect {target_host}:{target_port} failed: {exc}", file=sys.stderr, flush=True)
local_w.close()
return
await asyncio.gather(_pipe(local_r, remote_w), _pipe(remote_r, local_w))
return handle
async def run_static(listen_port: int, target_host: str, target_port: int) -> None:
server = await asyncio.start_server(_handler(target_host, target_port), "127.0.0.1", listen_port)
print(f"[relay] static 127.0.0.1:{listen_port} -> {target_host}:{target_port}", flush=True)
async with server:
await server.serve_forever()
async def _adb_forward_ports() -> Set[int]:
adb = os.environ.get("ADB", "adb")
proc = await asyncio.create_subprocess_exec(
adb, "forward", "--list",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
out, _ = await proc.communicate()
ports: Set[int] = set()
for line in out.decode("utf-8", "replace").splitlines():
parts = line.split()
if len(parts) >= 2 and parts[1].startswith("tcp:"):
try:
ports.add(int(parts[1][4:]))
except ValueError:
pass
return ports
async def run_dynamic(target_host: str, poll_seconds: float = 1.0) -> None:
active: Dict[int, asyncio.AbstractServer] = {}
print(f"[relay] dynamic watching adb forwards -> {target_host} (poll={poll_seconds}s)", flush=True)
while True:
try:
target_ports = await _adb_forward_ports()
except Exception as exc:
print(f"[relay] adb listing failed: {exc}", file=sys.stderr, flush=True)
await asyncio.sleep(poll_seconds)
continue
for port in target_ports - set(active):
try:
srv = await asyncio.start_server(_handler(target_host, port), "127.0.0.1", port)
active[port] = srv
print(f"[relay] + 127.0.0.1:{port} -> {target_host}:{port}", flush=True)
except OSError as exc:
print(f"[relay] bind 127.0.0.1:{port} failed: {exc}", file=sys.stderr, flush=True)
for port in set(active) - target_ports:
srv = active.pop(port)
srv.close()
try:
await srv.wait_closed()
except Exception:
pass
print(f"[relay] - 127.0.0.1:{port}", flush=True)
await asyncio.sleep(poll_seconds)
def _usage() -> None:
print(__doc__, file=sys.stderr)
sys.exit(2)
async def _main() -> None:
argv = sys.argv[1:]
if not argv:
_usage()
if argv[0] == "--watch-adb":
if len(argv) != 2:
_usage()
await run_dynamic(argv[1])
else:
if len(argv) != 3:
_usage()
await run_static(int(argv[0]), argv[1], int(argv[2]))
if __name__ == "__main__":
asyncio.run(_main())

View File

@@ -60,10 +60,12 @@ When a new value needs to flow from CC → app, prefer DB. When it's a deploy-fi
## FCM Channel Convention ## 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`: Single channel `halobestie_chat_v2` is shared by both apps and ships the branded `halobestie_notif.ogg` sound. Backend FCM payloads should always target this channel ID via `android.notification.channelId`:
```js ```js
android: { priority: 'high', notification: { channelId: 'halobestie_chat_v1' } } android: { priority: 'high', notification: { channelId: 'halobestie_chat_v2' } }
``` ```
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. **Channel must be created from native Android (Kotlin) code, not from Dart via `flutter_local_notifications`.** The plugin's `AndroidNotificationChannel` sets `AudioAttributes` with `CONTENT_TYPE_UNKNOWN`; on Android 13+ (API 33) this causes notifications to post and be tagged `isNoisy=true`, but `systemui` never requests audio focus and the sound is silently dropped. The native channel must use `setContentType(CONTENT_TYPE_SONIFICATION)` alongside `USAGE_NOTIFICATION`. See `MainActivity.kt` in both `client_app` and `mitra_app`. The Dart-side `AndroidNotificationChannel` definition stays in `notification_service.dart` so `flutter_local_notifications.show()` resolves the channel id, but its `createNotificationChannel` call is a no-op since the native channel already exists (channels are immutable on API 26+).
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_v3`) and update both apps' native MainActivity + Dart definition + backend 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

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

View File

@@ -26,6 +26,7 @@
// //
// on(eventName, handler) → subscribe to lifecycle events // on(eventName, handler) → subscribe to lifecycle events
// verifyWebhookToken(headerToken) → constant-time compare for webhook auth (used by route) // verifyWebhookToken(headerToken) → constant-time compare for webhook auth (used by route)
// xenditInvoiceMethodFromCode(payment_code) → catalog→Xendit invoice filter map (exported for tests)
// //
// registerPairingSubscriber() → wires pairing.service as a subscriber to payment_request.confirmed // registerPairingSubscriber() → wires pairing.service as a subscriber to payment_request.confirmed
// recordIntermediateFailure(...) → audit-only failure for flows with a fallback path // recordIntermediateFailure(...) → audit-only failure for flows with a fallback path
@@ -87,8 +88,54 @@ const xenditClient = () => {
return _xenditClient return _xenditClient
} }
const createXenditInvoice = async ({ paymentRequestId, amount, ttlMinutes, description }) => { // Map our catalog `payment_code` (Xendit channel-code style — `BCA_VIRTUAL_ACCOUNT`,
// `CARDS`, etc., per https://docs.xendit.co/docs/available-payment-channels) to the
// short token Xendit's Create-Invoice `paymentMethods[]` filter expects. The two
// vocabularies overlap a lot but are not identical — channel codes are descriptive
// (`BCA_VIRTUAL_ACCOUNT`), invoice filter values are by-payment-rail (`BCA`).
//
// Returning null means "no mapping" — caller should omit `paymentMethods` so Xendit
// shows the full multi-method picker (graceful degradation). Avoid throwing here —
// a stray unknown code shouldn't break invoice creation.
//
// Reference list (Xendit Create Invoice `payment_methods` accepted values, May 2026):
// Bank: BCA, BNI, BRI, BSI, BJB, MANDIRI, PERMATA, SAHABAT_SAMPOERNA
// Retail: ALFAMART, INDOMARET
// E-wallet: OVO, DANA, SHOPEEPAY, LINKAJA, JENIUSPAY, ASTRAPAY
// QR: QRIS Card: CREDIT_CARD Paylater: KREDIVO, AKULAKU, UANGME, ATOME
//
// Notes:
// - CIMB is NOT exposed by Xendit's invoice paymentMethods filter (only as a raw VA
// channel via the lower-level VA API), so CIMB_VIRTUAL_ACCOUNT intentionally
// returns null → multi-method page fallback.
export const xenditInvoiceMethodFromCode = (code) => {
if (!code || typeof code !== 'string') return null
const m = {
QRIS: 'QRIS',
OVO: 'OVO',
DANA: 'DANA',
SHOPEEPAY: 'SHOPEEPAY',
LINKAJA: 'LINKAJA',
ASTRAPAY: 'ASTRAPAY',
BCA_VIRTUAL_ACCOUNT: 'BCA',
BNI_VIRTUAL_ACCOUNT: 'BNI',
BRI_VIRTUAL_ACCOUNT: 'BRI',
BSI_VIRTUAL_ACCOUNT: 'BSI',
MANDIRI_VIRTUAL_ACCOUNT: 'MANDIRI',
PERMATA_VIRTUAL_ACCOUNT: 'PERMATA',
BJB_VIRTUAL_ACCOUNT: 'BJB',
BSS_VIRTUAL_ACCOUNT: 'SAHABAT_SAMPOERNA',
ALFAMART: 'ALFAMART',
INDOMARET: 'INDOMARET',
CARDS: 'CREDIT_CARD',
}
return m[code.toUpperCase()] ?? null
}
const createXenditInvoice = async ({ paymentRequestId, amount, ttlMinutes, description, preferredPaymentCode }) => {
const { successRedirectUrl, failureRedirectUrl } = getXenditConfig() const { successRedirectUrl, failureRedirectUrl } = getXenditConfig()
const invoiceMethod = xenditInvoiceMethodFromCode(preferredPaymentCode)
console.log('[xendit] createInvoice', { paymentRequestId, amount, preferredPaymentCode, invoiceMethod })
const inv = await xenditClient().Invoice.createInvoice({ const inv = await xenditClient().Invoice.createInvoice({
data: { data: {
externalId: paymentRequestId, // D4 — our UUID is the Xendit external_id externalId: paymentRequestId, // D4 — our UUID is the Xendit external_id
@@ -101,7 +148,10 @@ const createXenditInvoice = async ({ paymentRequestId, amount, ttlMinutes, descr
// Stamped so a shared webhook router (no DB access) can route v1 vs v2 traffic // Stamped so a shared webhook router (no DB access) can route v1 vs v2 traffic
// purely from the echoed payload. Keep this string stable — it is a routing key. // purely from the echoed payload. Keep this string stable — it is a routing key.
metadata: { app: 'halobestie_v2' }, metadata: { app: 'halobestie_v2' },
// paymentMethods omitted → honor dashboard config (operator picks methods without a deploy) // Lock the hosted page to the customer's chosen method when we have a mapping.
// When mapping returns null (unknown / unsupported code), omit the filter and
// let Xendit show the full picker so the customer can still pay.
...(invoiceMethod ? { paymentMethods: [invoiceMethod] } : {}),
}, },
}) })
return { invoiceId: inv.id, invoiceUrl: inv.invoiceUrl } return { invoiceId: inv.id, invoiceUrl: inv.invoiceUrl }
@@ -177,10 +227,10 @@ export const requestPayment = async ({
isExtension = false, isExtension = false,
targetedMitraId = null, targetedMitraId = null,
// Customer's pre-picked payment method from the catalog. Optional; // Customer's pre-picked payment method from the catalog. Optional;
// upper-cased Xendit channel code (e.g. `OVO`). Stamped onto // upper-cased channel code (e.g. `OVO`, `BCA_VIRTUAL_ACCOUNT`, `CARDS`).
// product_metadata for analytics + future use as a Xendit `paymentMethods` // Stamped onto product_metadata for analytics AND translated via
// filter. Not currently passed to Xendit invoice creation — the customer // xenditInvoiceMethodFromCode() into Xendit's invoice paymentMethods[]
// re-picks on Xendit's checkout page. // filter so the customer doesn't have to re-pick on the hosted page.
preferredPaymentCode = null, preferredPaymentCode = null,
}) => { }) => {
if (!customerId) { if (!customerId) {
@@ -239,6 +289,7 @@ export const requestPayment = async ({
amount: row.amount, amount: row.amount,
ttlMinutes: ttl, ttlMinutes: ttl,
description: buildInvoiceDescription(row), description: buildInvoiceDescription(row),
preferredPaymentCode,
}) })
await sql` await sql`
UPDATE payment_requests UPDATE payment_requests

View File

@@ -4,6 +4,7 @@ import {
confirmPaymentSession, confirmPaymentSession,
getPaymentSession, getPaymentSession,
getCustomerPendingPayments, getCustomerPendingPayments,
xenditInvoiceMethodFromCode,
} from '../../src/services/payment.service.js' } from '../../src/services/payment.service.js'
import { PaymentRequestStatus, SessionStatus } from '../../src/constants.js' import { PaymentRequestStatus, SessionStatus } from '../../src/constants.js'
import { resetDb, resetAppConfig, db } from '../helpers/db.js' import { resetDb, resetAppConfig, db } from '../helpers/db.js'
@@ -85,6 +86,45 @@ describe('payment.service', () => {
expect(reloaded.confirmed_at).toBeNull() expect(reloaded.confirmed_at).toBeNull()
}) })
// Phase 5.x — catalog payment_code → Xendit invoice paymentMethods filter.
// Pure function, no DB / no Xendit client touched.
describe('xenditInvoiceMethodFromCode', () => {
it('maps Virtual Account codes to bank rails', () => {
expect(xenditInvoiceMethodFromCode('BCA_VIRTUAL_ACCOUNT')).toBe('BCA')
expect(xenditInvoiceMethodFromCode('BNI_VIRTUAL_ACCOUNT')).toBe('BNI')
expect(xenditInvoiceMethodFromCode('MANDIRI_VIRTUAL_ACCOUNT')).toBe('MANDIRI')
expect(xenditInvoiceMethodFromCode('PERMATA_VIRTUAL_ACCOUNT')).toBe('PERMATA')
expect(xenditInvoiceMethodFromCode('BSS_VIRTUAL_ACCOUNT')).toBe('SAHABAT_SAMPOERNA')
})
it('maps e-wallet codes verbatim', () => {
expect(xenditInvoiceMethodFromCode('OVO')).toBe('OVO')
expect(xenditInvoiceMethodFromCode('DANA')).toBe('DANA')
expect(xenditInvoiceMethodFromCode('SHOPEEPAY')).toBe('SHOPEEPAY')
})
it('maps CARDS to CREDIT_CARD and QRIS verbatim', () => {
expect(xenditInvoiceMethodFromCode('CARDS')).toBe('CREDIT_CARD')
expect(xenditInvoiceMethodFromCode('QRIS')).toBe('QRIS')
})
it('is casing-tolerant', () => {
expect(xenditInvoiceMethodFromCode('bca_virtual_account')).toBe('BCA')
expect(xenditInvoiceMethodFromCode('Ovo')).toBe('OVO')
})
it('returns null for unknown / unsupported / falsy codes', () => {
// CIMB is not in Xendit's invoice paymentMethods filter list (only as a
// raw VA channel) — must degrade to the full-picker fallback, not throw.
expect(xenditInvoiceMethodFromCode('CIMB_VIRTUAL_ACCOUNT')).toBeNull()
expect(xenditInvoiceMethodFromCode('NOT_A_REAL_CODE')).toBeNull()
expect(xenditInvoiceMethodFromCode('')).toBeNull()
expect(xenditInvoiceMethodFromCode(null)).toBeNull()
expect(xenditInvoiceMethodFromCode(undefined)).toBeNull()
expect(xenditInvoiceMethodFromCode(42)).toBeNull()
})
})
// Phase 4 Stage 10 — Chat Tab Pembayaran feed. // Phase 4 Stage 10 — Chat Tab Pembayaran feed.
describe('getCustomerPendingPayments', () => { describe('getCustomerPendingPayments', () => {
it('returns empty when customer has no payments', async () => { it('returns empty when customer has no payments', async () => {

View File

@@ -0,0 +1,190 @@
# ts-customer-06-01 — End-of-session via the Time-up Sheet's "cukup,
# akhiri sesi" ghost CTA → ConfirmEndStep1 → ConfirmEndStep2 → Closing
# Message Sheet → /home.
#
# Spec ref: requirement/flow_customer.mermaid.md §6 (End-of-session
# sequence) + flow_customer.md "5.8.2.1.x".
#
# Feature under test:
# - PricingBottomSheet now exposes 2 CTAs: primary "perpanjang Rp..."
# (or "pilih durasi dulu" with no tier picked) AND a ghost
# "cukup, akhiri sesi" that opens the 2-step confirm chain. The
# four widgets — PricingBottomSheet, ConfirmEndStep1, ConfirmEndStep2,
# ClosingMessageSheet — already existed but weren't wired together
# before today's change in chat_screen.dart::_runEndSessionFlow.
#
# Path traced here: Time-up sheet → "cukup, akhiri sesi" → ConfirmEndStep1
# "lanjut akhiri" → ConfirmEndStep2 "tulis pesan penutup" → ClosingMessageSheet
# "kirim & akhiri sesi" → /home.
#
# Pre-reqs:
# - Backend reachable at BACKEND_INTERNAL_URL with NODE_ENV != 'production'
# (needs the /internal/_test/* harness endpoints).
# - ≥1 mitra online to accept the blast.
appId: com.mybestie
env:
TEST_PHONE: "+6281234567890"
BACKEND_INTERNAL_URL: http://localhost:3001
---
# ── Setup: auth + seed history + pair into an active chat ───────────────
- runScript:
file: ../scripts/reset_phone.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- launchApp:
clearState: true
- runFlow: ../subflows/onboarding_returning_user.yaml
- runScript:
file: ../scripts/seed_history_session.js
env:
TEST_PHONE: ${TEST_PHONE}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- swipe:
start: "50%, 30%"
end: "50%, 80%"
- extendedWaitUntil:
visible:
text: "(?s).*curhatan sebelumnya.*"
timeout: 10000
# Curhat sama bestie baru → choice sheet → bestie baru → method-pick
- tapOn:
text: "(?s).*curhat sama bestie baru.*"
retryTapIfNoChange: true
- extendedWaitUntil:
visible:
text: "(?s).*mau curhat sama siapa.*"
timeout: 5000
- tapOn: "(?s).*cari bestie baru yang siap dengerin.*"
# Payment chain → confirm → pairing accepted → on chat screen
- extendedWaitUntil:
visible:
text: "(?s).*pilih cara curhat.*"
timeout: 10000
- tapOn:
text: "(?s).*tulis dan baca dengan tenang.*"
- extendedWaitUntil:
visible:
text: "(?s).*pilih durasi.*"
timeout: 10000
- tapOn: "(?s).*5 menit.*"
- tapOn: "(?s).*bayar Rp.*"
- extendedWaitUntil:
visible:
text: "(?s).*cara bayar.*"
timeout: 10000
# Pick a payment method first — bayar CTA is disabled until one is
# selected. QRIS is always in the "paling cepat" section.
- tapOn: "QRIS"
- tapOn:
text: "(?s).*bayar Rp.*"
retryTapIfNoChange: true
# Brief settle so the POST /api/client/payment-requests/ completes
# (creates the pending row before mark_latest_payment_paid runs).
# Avoids asserting on QR/Xendit content since the visible screen
# depends on XENDIT_ENABLED — we don't care which renders, just that
# the payment row exists in the backend for the mark-paid script.
- waitForAnimationToEnd:
timeout: 5000
- runScript:
file: ../scripts/mark_latest_payment_paid.js
env:
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- runFlow:
when:
visible:
text: "(?s).*biar nggak ketinggalan.*"
commands:
- tapOn:
text: "(?s).*nanti aja.*"
- extendedWaitUntil:
visible:
text: "(?s).*lagi nyari bestie.*"
timeout: 20000
- runScript:
file: ../scripts/mitra_accept_latest_internal.js
env:
MITRA_ID: ${output.MITRA_ID}
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
- extendedWaitUntil:
visible:
text: "tulis sesuatu..."
timeout: 20000
# ── Force expires_at → 0 so the floating expired banner appears ─────────
- runScript:
file: ../scripts/force_session_expires_at.js
env:
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL}
SECONDS_FROM_NOW: "0"
# The expired banner copy is "habis nih... mau lanjutin curhat sama X?".
- extendedWaitUntil:
visible:
text: "(?s).*habis nih.*"
timeout: 10000
# ── Step 1: tap "perpanjang" on the banner → time-up sheet opens ───────
# Tap by Semantics identifier (id) — Maestro's tap-by-text and tap-by-point
# both fail to trigger this ElevatedButton's onPressed (raw `adb input
# tap` works, so it's a maestro/dadb gesture quirk specific to this
# button). `id:` routes through the Android a11y framework's
# performAction(CLICK), which fires onPressed cleanly. The matching
# Semantics wrapper is in chat_expired_banner.dart.
- tapOn:
id: "chat_extend_button"
- extendedWaitUntil:
visible:
text: "cukup, akhiri sesi"
timeout: 20000
# ── Step 2: assert primary CTA also renders + sheet header ──────────────
# Primary "perpanjang Rp..." renders as "pilih durasi dulu" before any
# tier is selected; that's the relevant assertion for the 2-CTA layout.
- assertVisible: "pilih durasi dulu"
- assertVisible: "waktu curhat habis"
# ── Step 3: tap "cukup, akhiri sesi" → ConfirmEndStep1 popup ────────────
- tapOn: "cukup, akhiri sesi"
- extendedWaitUntil:
visible:
text: "yakin mau akhiri sesi?"
timeout: 5000
- assertVisible: "lanjut akhiri"
- assertVisible: "gak jadi, balik"
# ── Step 4: tap "lanjut akhiri" → ConfirmEndStep2 popup ─────────────────
- tapOn: "lanjut akhiri"
- extendedWaitUntil:
visible:
text: "mau tinggalin pesan penutup?"
timeout: 5000
- assertVisible: "tulis pesan penutup"
- assertVisible: "lewati saja"
# ── Step 5: tap "tulis pesan penutup" → ClosingMessageSheet ─────────────
- tapOn: "tulis pesan penutup"
- extendedWaitUntil:
visible:
text: "pesan penutup"
timeout: 5000
- assertVisible: "kirim & akhiri sesi"
- assertVisible: "lewat — langsung akhiri"
# ── Step 6: type + send → closes session, navigates to /home ────────────
- tapOn:
text: "makasih ya bestie..."
- inputText: "makasih bestie, sesi ini ngebantu banget"
- hideKeyboard
- tapOn: "kirim & akhiri sesi"
# Home is the landing zone after closure. Customer has seeded + just-ended
# sessions in their history, so they're in the "returning" home variant —
# CTA is "curhat sama bestie baru", not "aku mau curhat".
- extendedWaitUntil:
visible:
text: "(?s).*curhat sama bestie baru.*"
timeout: 15000

View File

@@ -14,11 +14,13 @@
# - NODE_ENV != 'production' on backend (so /internal/_test routes exist). # - NODE_ENV != 'production' on backend (so /internal/_test routes exist).
# #
# Path taken: # Path taken:
# Welcome carousel ("Mulai") → Home with anon banner → # Splash auto-advances (2026-05-26 — the 3-page welcome carousel was
# tap "masuk →" → /auth/register → enter +62 subscriber digits # removed; splash self-drives to /home via context.go after ~2.5s)
# "kirim kode" → OTP screen → peek OTP from stub → auto-submit → # Home with anon banner → tap "masuk →"/auth/register → enter +62
# /auth/set-name (because AuthNeedsDisplayNameData) → enter "Maestro" # subscriber digits → "kirim kode" → OTP screen → peek OTP from stub
# "Lanjut" → /home (returning view: "curhatan sebelumnya" header). # auto-submit → /auth/set-name (because AuthNeedsDisplayNameData) →
# enter "Maestro" → "Lanjut" → /home (returning view: "curhatan
# sebelumnya" header).
# #
# Selector style: Flutter merges sibling Text widgets inside a single # Selector style: Flutter merges sibling Text widgets inside a single
# tappable parent into ONE accessibility blob. Maestro's `text:` selector # tappable parent into ONE accessibility blob. Maestro's `text:` selector
@@ -28,15 +30,8 @@
# inside the field's pill before typing. # inside the field's pill before typing.
appId: ${APP_ID_ANDROID} appId: ${APP_ID_ANDROID}
--- ---
# Welcome carousel — the "Mulai" button is the only Text inside its # Splash self-navigates to /home; nothing for the test to tap. Move
# tappable region, so a plain selector works. # straight to the home-screen anon banner assertion.
- extendedWaitUntil:
visible:
text: "Mulai"
timeout: 15000
- tapOn:
text: "Mulai"
retryTapIfNoChange: true
# Home — login-recover banner ("udah pernah pakai HaloBestie? … masuk →") # Home — login-recover banner ("udah pernah pakai HaloBestie? … masuk →")
# is one InkWell, so any token within its merged blob reaches the # is one InkWell, so any token within its merged blob reaches the
@@ -56,12 +51,21 @@ appId: ${APP_ID_ANDROID}
timeout: 10000 timeout: 10000
# Phone input — +62 is a static prefix chip; type only subscriber digits # Phone input — +62 is a static prefix chip; type only subscriber digits
# (no leading 0). +6281234567890 → 81234567890. Tap by point inside the # (no leading 0). +6281234567890 → 81234567890.
# pill (well right of the +62 chip, well above the kirim-kode button). #
# Selector strategy: the empty TextField has no a11y text on its own, but
# its `hintText` ("812 3456 7890" — the placeholder phone-number group)
# IS rendered as a Text widget inside the field's hit area, so tapping
# the hint focuses the TextField directly. The 2026-05-26 register-screen
# polish added a back button + logo above the field, breaking the
# pre-existing (60%, 47%) tap-point.
#
# We deliberately DO NOT call `hideKeyboard` here: maestro's
# `hideKeyboard` sends an Android BACK keypress, which pops the register
# screen back to /home when the soft keyboard isn't actually up.
- tapOn: - tapOn:
point: "60%, 47%" text: "(?s).*812 3456 7890.*"
- inputText: "81234567890" - inputText: "81234567890"
- hideKeyboard
- tapOn: - tapOn:
text: "(?s).*kirim kode.*" text: "(?s).*kirim kode.*"

View File

@@ -42,6 +42,20 @@
<data android:scheme="halobestie"/> <data android:scheme="halobestie"/>
</intent-filter> </intent-filter>
</activity> </activity>
<!-- FCM default notification icon + tint color. Used by the
background-display path (when the OS, not the app, renders the
FCM notification). Foreground path overrides via
AndroidNotificationDetails in notification_service.dart.
ic_launcher_foreground is the adaptive icon's foreground layer
— for the customer app it's the white HaloBestie logo on
transparent, which Android tints with the color below. -->
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/ic_launcher_foreground" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_color"
android:resource="@color/ic_launcher_background" />
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data <meta-data

View File

@@ -1,5 +1,52 @@
package com.mybestie package com.mybestie
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.media.AudioAttributes
import android.net.Uri
import android.os.Build
import android.os.Bundle
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity() class MainActivity : FlutterActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
createNotificationChannel()
}
// Create our notification channel natively (not via flutter_local_notifications)
// because the plugin's AndroidNotificationChannel sets AudioAttributes with
// CONTENT_TYPE_UNKNOWN, which Android 13+ silently treats as non-noisy for
// routing purposes — the notification posts and is marked isNoisy=true, but
// systemui never requests audio focus and the sound never plays. We need
// CONTENT_TYPE_SONIFICATION on the channel's audio attributes; the only way
// to set that is via the platform NotificationChannel.setSound(uri, attrs) API.
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val channelId = "halobestie_chat_v2"
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
// Channels are immutable on API 26+ — once created, sound/importance/audio-attrs
// can only be changed by the user via system settings. Skip if already there.
if (nm.getNotificationChannel(channelId) != null) return
val soundUri = Uri.parse("android.resource://$packageName/raw/halobestie_notif")
val audioAttributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
val channel = NotificationChannel(
channelId,
"Chat HaloBestie",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Notifications for incoming chat messages and pairing requests"
setSound(soundUri, audioAttributes)
enableVibration(true)
enableLights(false)
}
nm.createNotificationChannel(channel)
}
}

View File

@@ -78,6 +78,14 @@ class AuthNeedsDisplayNameData extends AuthData {
class Auth extends _$Auth { class Auth extends _$Auth {
final _storage = TokenStorage(); final _storage = TokenStorage();
// Coalesce concurrent _refreshFromStorage calls. The backend rotates the
// refresh token on every successful call — so if `build()` and an
// api_client 401-retry both fire refresh on the same stored token, one
// wins, the other gets 401, and the loser's catch block calls
// `_storage.clear()`, wiping the freshly-rotated token. Sharing a single
// in-flight Future across all callers prevents the double-fire.
Future<Map<String, dynamic>?>? _inFlightRefresh;
ApiClient get _apiClient => ref.read(apiClientProvider); ApiClient get _apiClient => ref.read(apiClientProvider);
AuthBridge get _bridge => ref.read(authBridgeProvider); AuthBridge get _bridge => ref.read(authBridgeProvider);
@@ -96,7 +104,12 @@ class Auth extends _$Auth {
// ---------------- Refresh / bootstrap ---------------- // ---------------- Refresh / bootstrap ----------------
Future<Map<String, dynamic>?> _refreshFromStorage() async { Future<Map<String, dynamic>?> _refreshFromStorage() {
return _inFlightRefresh ??= _doRefreshFromStorage()
.whenComplete(() => _inFlightRefresh = null);
}
Future<Map<String, dynamic>?> _doRefreshFromStorage() async {
final refreshToken = await _storage.readRefreshToken(); final refreshToken = await _storage.readRefreshToken();
if (refreshToken == null) return null; if (refreshToken == null) return null;
@@ -106,7 +119,7 @@ class Auth extends _$Auth {
data: {'refresh_token': refreshToken}, data: {'refresh_token': refreshToken},
skipAuth: true, skipAuth: true,
); );
return _applyTokens(response); return await _applyTokens(response);
} catch (_) { } catch (_) {
await _storage.clear(); await _storage.clear();
_bridge.clear(); _bridge.clear();

View File

@@ -7,22 +7,46 @@ import 'package:go_router/go_router.dart';
class NotificationService { class NotificationService {
static final _localNotifications = FlutterLocalNotificationsPlugin(); static final _localNotifications = FlutterLocalNotificationsPlugin();
static GoRouter? _router; static GoRouter? _router;
static void Function(Map<String, dynamic> data)? _onDataMessage;
static bool _initialized = false;
// Channel ID bumped (`chat_messages` → `halobestie_chat_v1`) when the // Channel ID bumped to v2 — v1 was created by flutter_local_notifications
// branded notification sound was introduced. Android binds sound to a // with AudioAttributes content_type=CONTENT_TYPE_UNKNOWN (the plugin's
// channel at create time on API 26+, so an existing channel can't pick // AndroidNotificationChannel doesn't expose contentType), which Android 13+
// up a new sound — a fresh ID is the only way. Backend FCM payloads // silently treats as non-noisy when routing — notifications post and are
// target the same ID — see backend/src/services/notification.service.js. // tagged isNoisy=true, but systemui never requests audio focus and the
// sound never plays. v2 is created natively in MainActivity.kt with
// CONTENT_TYPE_SONIFICATION so audio routing works. Backend FCM payloads
// target this ID — see backend/src/services/notification.service.js.
//
// We still pass the channel definition here so `_localNotifications.show()`
// has the channel id/name/description; createNotificationChannel below is a
// no-op when the native channel already exists (channels are immutable on
// API 26+).
static const _channel = AndroidNotificationChannel( static const _channel = AndroidNotificationChannel(
'halobestie_chat_v1', 'halobestie_chat_v2',
'Chat HaloBestie', 'Chat HaloBestie',
description: 'Notifications for incoming chat messages and pairing requests', description: 'Notifications for incoming chat messages and pairing requests',
importance: Importance.high, importance: Importance.high,
sound: RawResourceAndroidNotificationSound('halobestie_notif'), sound: RawResourceAndroidNotificationSound('halobestie_notif'),
); );
static Future<void> initialize(GoRouter router) async { /// `onDataMessage` is invoked with the FCM `data` payload on every delivery
/// (foreground, background-tap, or cold-start tap). It runs *in addition to*
/// — and before — the navigation logic, so callers can sync app state (e.g.
/// advance the PairingNotifier when a `paired` push arrives because the WS
/// path failed). Keep it side-effect-only; don't block.
static Future<void> initialize(
GoRouter router, {
void Function(Map<String, dynamic> data)? onDataMessage,
}) async {
_router = router; _router = router;
_onDataMessage = onDataMessage;
// Guard against re-attaching FCM listeners on every rebuild — main.dart
// calls this from `build()`, so without the flag we'd accumulate one
// extra subscription per rebuild and fire the handler N times per push.
if (_initialized) return;
_initialized = true;
// Create Android notification channel // Create Android notification channel
await _localNotifications await _localNotifications
@@ -52,9 +76,19 @@ class NotificationService {
} }
static void _onForegroundMessage(RemoteMessage message) { static void _onForegroundMessage(RemoteMessage message) {
// Always dispatch the data payload first so app state stays in sync
// (e.g. PairingNotifier advances on `paired`) even if there's no
// notification body to display.
_onDataMessage?.call(message.data);
final notification = message.notification; final notification = message.notification;
if (notification == null) return; if (notification == null) return;
// `paired` while the app is already foreground: the pairing state will
// navigate the user to the chat screen in ~2s — a redundant banner on
// top of that transition is just noise.
if (message.data['type'] == 'paired') return;
_localNotifications.show( _localNotifications.show(
id: notification.hashCode, id: notification.hashCode,
title: notification.title, title: notification.title,
@@ -66,6 +100,12 @@ class NotificationService {
channelDescription: _channel.description, channelDescription: _channel.description,
importance: Importance.high, importance: Importance.high,
priority: Priority.high, priority: Priority.high,
// Foreground-path icon override. The OS will tint this monochrome
// (alpha-only content) and apply the color set in AndroidManifest's
// default_notification_color meta-data. ic_launcher_foreground is
// the adaptive icon's foreground layer (white HaloBestie logo on
// transparent) — renders as the brand-pink HaloBestie silhouette.
icon: 'ic_launcher_foreground',
// API 26+ ignores this in favor of the channel's sound; included // API 26+ ignores this in favor of the channel's sound; included
// for the API 24/25 path where channels don't exist yet. // for the API 24/25 path where channels don't exist yet.
sound: const RawResourceAndroidNotificationSound('halobestie_notif'), sound: const RawResourceAndroidNotificationSound('halobestie_notif'),
@@ -93,6 +133,10 @@ class NotificationService {
} }
static void _navigateFromMessage(Map<String, dynamic> data) { static void _navigateFromMessage(Map<String, dynamic> data) {
// Dispatch first so PairingNotifier / other state owners can react,
// even if we end up bailing out of the navigation branches below.
_onDataMessage?.call(data);
if (_router == null) return; if (_router == null) return;
final sessionId = data['session_id'] as String?; final sessionId = data['session_id'] as String?;
final type = data['type'] as String?; final type = data['type'] as String?;

View File

@@ -186,7 +186,17 @@ class Pairing extends _$Pairing {
paymentRequestId: paymentRequestId, paymentRequestId: paymentRequestId,
); );
} else if (code == 'ALREADY_ACTIVE') { } else if (code == 'ALREADY_ACTIVE') {
state = const PairingErrorData('Kamu sudah memiliki sesi aktif.'); // Stale in-flight session/request on the backend. The session may
// already have flipped to ACTIVE (mitra accepted the previous
// attempt while this new request was in-flight) — refresh now so
// the activeSession listener in main.dart can advance us out of
// this error without waiting for the next 15s poll tick. If the
// pre-existing session is still SEARCHING / PENDING_ACCEPTANCE,
// the next poll that surfaces an ACTIVE session will trigger the
// same recovery via applyPairedFromPush(PairingErrorData → ...).
state = const PairingErrorData('Kamu sudah memiliki sesi aktif. Menyambungkan...');
// ignore: discarded_futures
ref.read(activeSessionProvider.notifier).refresh();
} else { } else {
state = const PairingErrorData('Gagal memulai. Coba lagi.'); state = const PairingErrorData('Gagal memulai. Coba lagi.');
} }
@@ -322,6 +332,43 @@ class Pairing extends _$Pairing {
state = const PairingInitialData(); state = const PairingInitialData();
} }
/// Out-of-band "paired" signal — fired when the backend's WS push to the
/// customer failed and the FCM fallback (or a post-resume activeSession
/// refresh) is the first thing to inform us that pairing succeeded.
///
/// Mirrors the WS `paired` branch in `_onWsEvent`: drops the WS+countdown,
/// flashes `BestieFoundData` for ~2s, then settles into `ActiveData`.
/// Idempotent — bails when we already know about this sessionId (so a
/// foreground FCM + activeSession-poll race doesn't double-fire).
///
/// Also accepts [PairingErrorData] as a valid prior state: the
/// `ALREADY_ACTIVE` recovery path lands the notifier in error while the
/// pre-existing session is still pending acceptance on the backend, and the
/// activeSession poll is what eventually surfaces the paired session.
Future<void> applyPairedFromPush({
required String sessionId,
required String mitraName,
}) async {
final current = state;
if (current is PairingBestieFoundData && current.sessionId == sessionId) return;
if (current is PairingActiveData && current.sessionId == sessionId) return;
if (current is! PairingSearchingData &&
current is! PairingTargetedWaitingData &&
current is! PairingInitialData &&
current is! PairingErrorData) {
return;
}
_cleanup();
state = PairingBestieFoundData(sessionId: sessionId, mitraName: mitraName);
// ignore: unawaited_futures
ref.read(activeSessionProvider.notifier).refresh();
await Future.delayed(const Duration(seconds: 2));
if (state is PairingBestieFoundData &&
(state as PairingBestieFoundData).sessionId == sessionId) {
state = PairingActiveData(sessionId: sessionId, mitraName: mitraName);
}
}
/// "Coba Cari Lagi" CTA on the S7 Timeout screen when the payment was kept /// "Coba Cari Lagi" CTA on the S7 Timeout screen when the payment was kept
/// `confirmed` (retryable failure). Re-blasts on the same payment session. /// `confirmed` (retryable failure). Re-blasts on the same payment session.
/// ///

View File

@@ -10,6 +10,9 @@ import '../../../core/theme/halo_tokens.dart';
import '../../../core/theme/widgets/halo_snackbar.dart'; import '../../../core/theme/widgets/halo_snackbar.dart';
import '../widgets/bestie_unavailable_dialog.dart'; import '../widgets/bestie_unavailable_dialog.dart';
import '../widgets/chat_expired_banner.dart'; import '../widgets/chat_expired_banner.dart';
import '../widgets/closing_message_sheet.dart';
import '../widgets/confirm_end_step1.dart';
import '../widgets/confirm_end_step2.dart';
import '../widgets/pricing_bottom_sheet.dart'; import '../widgets/pricing_bottom_sheet.dart';
/// S10 Chat Room — strict Figma implementation (Phase 4, 2026-05-12). /// S10 Chat Room — strict Figma implementation (Phase 4, 2026-05-12).
@@ -509,7 +512,14 @@ class _TimerBanner extends ConsumerWidget {
return _BannerKind.none; return _BannerKind.none;
})); }));
void onExtend() { void onExtend() {
PricingBottomSheet.showForExtension(context, sessionId: sessionId); PricingBottomSheet.showForExtension(
context,
sessionId: sessionId,
// Figma 28→29→30→31: time-up sheet → confirm popup 1 → confirm popup
// 2 → closing-message sheet (or skip → home). The sheet pops itself
// before this fires, so the first dialog stacks on the chat route.
onEndSession: () => _runEndSessionFlow(context, ref, sessionId),
);
} }
switch (kind) { switch (kind) {
case _BannerKind.lowTime: case _BannerKind.lowTime:
@@ -522,6 +532,44 @@ class _TimerBanner extends ConsumerWidget {
} }
} }
// Orchestrates the two-step confirm popup chain that follows the time-up
// sheet's "cukup, akhiri sesi" CTA. ConfirmEndStep1/2 already pop themselves
// before invoking the action callback (see HaloPopup), so we can chain
// without manual Navigator.pop calls. The skip path closes the session via
// the closure notifier and routes home; the "tulis pesan" path delegates to
// the ClosingMessageSheet which calls closeSession itself in its onCompleted.
Future<void> _runEndSessionFlow(
BuildContext context,
WidgetRef ref,
String sessionId,
) async {
await ConfirmEndStep1.show(
context,
onConfirm: () async {
if (!context.mounted) return;
await ConfirmEndStep2.show(
context,
onWriteMessage: () {
if (!context.mounted) return;
ClosingMessageSheet.show(
context,
sessionId: sessionId,
onCompleted: () {
if (context.mounted) context.go('/home');
},
);
},
onSkip: () async {
await ref
.read(sessionClosureProvider.notifier)
.closeSession(sessionId);
if (context.mounted) context.go('/home');
},
);
},
);
}
// ─── Header (back · orb · name+status · timer pill) + progress bar ────────── // ─── Header (back · orb · name+status · timer pill) + progress bar ──────────
class _ChatHeader extends ConsumerStatefulWidget { class _ChatHeader extends ConsumerStatefulWidget {

View File

@@ -57,7 +57,19 @@ class ChatExpiredBanner extends StatelessWidget {
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
ElevatedButton( // Semantics wrapper exposes the button via Android's resource-id so
// maestro can find it via `id:` selector AND triggers onExtend
// directly on a11y CLICK action (instead of synthesized coord-tap
// which doesn't reliably fire this ElevatedButton's onPressed —
// confirmed: raw `adb input tap` works, maestro's tap does not).
// `onTap: onExtend` makes the Semantics node itself clickable so
// Android's UiAutomator routes node.performAction(ACTION_CLICK)
// → onExtend without needing the child button's gesture detector.
Semantics(
identifier: 'chat_extend_button',
button: true,
onTap: onExtend,
child: ElevatedButton(
onPressed: onExtend, onPressed: onExtend,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: Colors.white, backgroundColor: Colors.white,
@@ -74,6 +86,7 @@ class ChatExpiredBanner extends StatelessWidget {
), ),
child: const Text('perpanjang'), child: const Text('perpanjang'),
), ),
),
], ],
), ),
); );

View File

@@ -21,21 +21,42 @@ class PricingBottomSheet extends ConsumerStatefulWidget {
/// Required — the in-progress chat session id this extension targets. /// Required — the in-progress chat session id this extension targets.
final String extensionSessionId; final String extensionSessionId;
const PricingBottomSheet({super.key, required this.extensionSessionId}); /// Optional — fired when the customer chooses the "cukup, akhiri sesi"
/// ghost CTA. The sheet pops itself before invoking the callback so the
/// downstream confirm-end popup chain can stack on a clean route.
final VoidCallback? onEndSession;
const PricingBottomSheet({
super.key,
required this.extensionSessionId,
this.onEndSession,
});
/// Show for session extension (from chat screen). /// Show for session extension (from chat screen).
static Future<void> showForExtension(BuildContext context, {required String sessionId}) { static Future<void> showForExtension(
BuildContext context, {
required String sessionId,
VoidCallback? onEndSession,
}) {
return showModalBottomSheet( return showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
backgroundColor: HaloTokens.surface, backgroundColor: HaloTokens.surface,
// Material 3 auto-injects a drag-handle widget above the sheet when
// the theme has one configured. We render our own pill inside
// `_Body` to control its exact tint/size — explicitly disable the
// auto one so the user doesn't see two stacked horizontal lines.
showDragHandle: false,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only( borderRadius: BorderRadius.only(
topLeft: Radius.circular(24), topLeft: Radius.circular(24),
topRight: Radius.circular(24), topRight: Radius.circular(24),
), ),
), ),
builder: (_) => PricingBottomSheet(extensionSessionId: sessionId), builder: (_) => PricingBottomSheet(
extensionSessionId: sessionId,
onEndSession: onEndSession,
),
); );
} }
@@ -72,6 +93,11 @@ class _PricingBottomSheetState extends ConsumerState<PricingBottomSheet> {
); );
} }
void _onEndSession() {
Navigator.of(context).pop();
widget.onEndSession?.call();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final pricingAsync = ref.watch(chatPricingProvider); final pricingAsync = ref.watch(chatPricingProvider);
@@ -105,6 +131,7 @@ class _PricingBottomSheetState extends ConsumerState<PricingBottomSheet> {
}), }),
onTierTap: _onTierTap, onTierTap: _onTierTap,
onConfirm: _onConfirm, onConfirm: _onConfirm,
onEndSession: widget.onEndSession != null ? _onEndSession : null,
), ),
), ),
); );
@@ -122,6 +149,7 @@ class _Body extends StatelessWidget {
final ValueChanged<PaymentMode> onModeChanged; final ValueChanged<PaymentMode> onModeChanged;
final ValueChanged<PriceTier> onTierTap; final ValueChanged<PriceTier> onTierTap;
final ValueChanged<PriceTier> onConfirm; final ValueChanged<PriceTier> onConfirm;
final VoidCallback? onEndSession;
const _Body({ const _Body({
required this.pricing, required this.pricing,
@@ -132,6 +160,7 @@ class _Body extends StatelessWidget {
required this.onModeChanged, required this.onModeChanged,
required this.onTierTap, required this.onTierTap,
required this.onConfirm, required this.onConfirm,
required this.onEndSession,
}); });
@override @override
@@ -206,22 +235,36 @@ class _Body extends StatelessWidget {
}, },
), ),
), ),
Container( Padding(
// No top border here — the drag handle is the only top "divider"
// the figma shows. A second border above the action bar reads as a
// duplicate line stacked under the list separator visuals.
padding: const EdgeInsets.fromLTRB( padding: const EdgeInsets.fromLTRB(
HaloSpacing.s24, HaloSpacing.s24,
HaloSpacing.s8, HaloSpacing.s12,
HaloSpacing.s24,
HaloSpacing.s24, HaloSpacing.s24,
HaloSpacing.s16,
), ),
decoration: const BoxDecoration( child: Column(
border: Border(top: BorderSide(color: HaloTokens.border)), mainAxisSize: MainAxisSize.min,
), children: [
child: HaloButton( HaloButton(
label: ctaLabel, label: ctaLabel,
size: HaloButtonSize.lg, size: HaloButtonSize.lg,
fullWidth: true, fullWidth: true,
onPressed: hasSelection ? () => onConfirm(selectedTier) : null, onPressed: hasSelection ? () => onConfirm(selectedTier) : null,
), ),
if (onEndSession != null) ...[
const SizedBox(height: HaloSpacing.s8),
HaloButton(
label: 'cukup, akhiri sesi',
variant: HaloButtonVariant.ghost,
fullWidth: true,
onPressed: onEndSession,
),
],
],
),
), ),
], ],
); );

View File

@@ -10,6 +10,10 @@ class BestieHistoryItem {
final List<String> topics; final List<String> topics;
final int sessionsCount; final int sessionsCount;
final bool mitraIsOnline; final bool mitraIsOnline;
// Raw session status — needed by the picker screen to distinguish the
// closing-row branch (one-time grace path into the goodbye composer) from
// a regular re-pair tap.
final String? status;
const BestieHistoryItem({ const BestieHistoryItem({
required this.sessionId, required this.sessionId,
@@ -19,6 +23,7 @@ class BestieHistoryItem {
required this.topics, required this.topics,
required this.sessionsCount, required this.sessionsCount,
required this.mitraIsOnline, required this.mitraIsOnline,
required this.status,
}); });
factory BestieHistoryItem.fromJson(Map<String, dynamic> json) { factory BestieHistoryItem.fromJson(Map<String, dynamic> json) {
@@ -38,6 +43,7 @@ class BestieHistoryItem {
_ => 1, _ => 1,
}, },
mitraIsOnline: json['mitra_is_online'] as bool? ?? false, mitraIsOnline: json['mitra_is_online'] as bool? ?? false,
status: json['status'] as String?,
); );
} }
} }

View File

@@ -1,7 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../core/api/api_client_provider.dart';
import '../../../core/constants.dart'; import '../../../core/constants.dart';
import '../../../core/theme/halo_tokens.dart'; import '../../../core/theme/halo_tokens.dart';
import '../../../core/theme/widgets/widgets.dart'; import '../../../core/theme/widgets/widgets.dart';
@@ -36,7 +35,6 @@ class BestieHistoryListScreen extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final historyAsync = ref.watch(bestieHistoryProvider); final historyAsync = ref.watch(bestieHistoryProvider);
final rawAsync = ref.watch(_rawHistoryProvider);
return Scaffold( return Scaffold(
backgroundColor: HaloTokens.bg, backgroundColor: HaloTokens.bg,
@@ -81,7 +79,6 @@ class BestieHistoryListScreen extends ConsumerWidget {
return RefreshIndicator( return RefreshIndicator(
onRefresh: () async { onRefresh: () async {
ref.invalidate(bestieHistoryProvider); ref.invalidate(bestieHistoryProvider);
ref.invalidate(_rawHistoryProvider);
await ref.read(bestieHistoryProvider.future); await ref.read(bestieHistoryProvider.future);
}, },
child: ListView.separated( child: ListView.separated(
@@ -110,8 +107,7 @@ class BestieHistoryListScreen extends ConsumerWidget {
); );
} }
final item = items[index - 1]; final item = items[index - 1];
final raw = rawAsync.valueOrNull?[index - 1]; final isClosing = item.status == SessionStatus.closing;
final isClosing = raw?['status'] == SessionStatus.closing;
return _BestieRow( return _BestieRow(
item: item, item: item,
isClosing: isClosing, isClosing: isClosing,
@@ -157,16 +153,6 @@ class BestieHistoryListScreen extends ConsumerWidget {
} }
} }
/// Raw history payload — used to read fields the v4 `BestieHistoryItem`
/// model doesn't surface (currently `status`, for the closing-row branch).
final _rawHistoryProvider =
FutureProvider<List<Map<String, dynamic>>>((ref) async {
final api = ref.read(apiClientProvider);
final response = await api.get('/api/client/chat/history');
return ((response['data']['items'] as List?) ?? const [])
.cast<Map<String, dynamic>>();
});
class _BestieRow extends StatelessWidget { class _BestieRow extends StatelessWidget {
final BestieHistoryItem item; final BestieHistoryItem item;
final bool isClosing; final bool isClosing;

View File

@@ -19,6 +19,11 @@ class MethodPickScreen extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
return Scaffold( return Scaffold(
backgroundColor: HaloTokens.bg, backgroundColor: HaloTokens.bg,
// No text inputs on this screen — but the previous screen
// (display_name) leaves the soft keyboard up for a frame or two after
// navigation. The default resize behavior shrinks the body during
// that window and triggers a brief overflow on the two-card column.
resizeToAvoidBottomInset: false,
appBar: AppBar( appBar: AppBar(
backgroundColor: HaloTokens.bg, backgroundColor: HaloTokens.bg,
elevation: 0, elevation: 0,

View File

@@ -4,11 +4,11 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:qr_flutter/qr_flutter.dart'; import 'package:qr_flutter/qr_flutter.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../../core/api/api_client_provider.dart'; import '../../../core/api/api_client_provider.dart';
import '../../../core/constants.dart'; import '../../../core/constants.dart';
import '../../../core/theme/halo_tokens.dart'; import '../../../core/theme/halo_tokens.dart';
import '../state/payment_draft_provider.dart'; import '../state/payment_draft_provider.dart';
import 'xendit_checkout_screen.dart';
/// "Waiting payment" — placeholder QR + 20-minute countdown header. Polls the /// "Waiting payment" — placeholder QR + 20-minute countdown header. Polls the
/// backend every 3 seconds for status changes. On `confirmed` the payment is /// backend every 3 seconds for status changes. On `confirmed` the payment is
@@ -34,10 +34,17 @@ class _WaitingPaymentScreenState extends ConsumerState<WaitingPaymentScreen>
DateTime? _expiresAt; DateTime? _expiresAt;
int _amount = 0; int _amount = 0;
String? _qrPayload; String? _qrPayload;
// Phase 5 (Xendit on): when the backend's payment session payload includes
// `xendit_invoice_url` (or legacy `invoice_url`), we host it inside the app
// via [XenditCheckoutScreen] instead of rendering the QR mock. The QR path
// still applies when XENDIT_ENABLED=false (dev/Maestro).
String? _invoiceUrl;
bool _initialLoading = true; bool _initialLoading = true;
bool _terminal = false; bool _terminal = false;
String? _error; String? _error;
bool _invoiceUrlLaunched = false; // Phase 5: only auto-launch the Custom Tab once // Auto-push the embedded WebView once on first load. The "Buka ulang
// halaman pembayaran" button can re-push manually after that.
bool _invoiceUrlLaunched = false;
Duration get _remaining { Duration get _remaining {
final exp = _expiresAt; final exp = _expiresAt;
@@ -76,37 +83,49 @@ class _WaitingPaymentScreenState extends ConsumerState<WaitingPaymentScreen>
if (!mounted || session == null) return; if (!mounted || session == null) return;
final expiresAtRaw = session['expires_at'] as String?; final expiresAtRaw = session['expires_at'] as String?;
final expiresAt = expiresAtRaw != null ? DateTime.tryParse(expiresAtRaw) : null; final expiresAt = expiresAtRaw != null ? DateTime.tryParse(expiresAtRaw) : null;
final invoiceUrl =
(session['xendit_invoice_url'] as String?) ?? (session['invoice_url'] as String?);
setState(() { setState(() {
_amount = (session['amount'] as int?) ?? 0; _amount = (session['amount'] as int?) ?? 0;
_expiresAt = expiresAt; _expiresAt = expiresAt;
_qrPayload = (session['qr_string'] as String?) ?? widget.paymentId; _qrPayload = (session['qr_string'] as String?) ?? widget.paymentId;
_invoiceUrl = (invoiceUrl != null && invoiceUrl.isNotEmpty) ? invoiceUrl : null;
_initialLoading = false; _initialLoading = false;
}); });
// Phase 5: when Xendit is on, the backend returns an `xendit_invoice_url` // Phase 5: when Xendit is on, host the invoice page inside the app via
// (Xendit's hosted checkout). Open it in a Custom Tab (Android) / // an embedded WebView (XenditCheckoutScreen). Auto-push once on first
// SFSafariViewController (iOS) so the customer stays inside the app's // load; the "Buka ulang halaman pembayaran" button re-pushes manually if
// browser context. Fire-and-forget — polling continues regardless. // the customer accidentally closed it. When Xendit is off (dev/Maestro),
// When Xendit is off (dev/Maestro), invoice_url is null and the QR fallback below is used. // _invoiceUrl is null and the QR fallback is rendered instead.
await _maybeLaunchInvoiceUrl(session); _maybeOpenCheckoutScreen();
_maybeHandleStatus(session); _maybeHandleStatus(session);
_startTicker(); _startTicker();
_resumePolling(); _resumePolling();
} }
Future<void> _maybeLaunchInvoiceUrl(Map<String, dynamic> session) async { /// Opens the embedded Xendit WebView. Auto-called once on first load when
if (_invoiceUrlLaunched) return; /// `_invoiceUrl` is non-null; also wired to the "Buka ulang halaman
final url = (session['xendit_invoice_url'] as String?) ?? (session['invoice_url'] as String?); /// pembayaran" button (which forces a re-push regardless of the flag).
if (url == null || url.isEmpty) return; Future<void> _maybeOpenCheckoutScreen({bool force = false}) async {
final url = _invoiceUrl;
if (url == null) return;
if (!force && _invoiceUrlLaunched) return;
_invoiceUrlLaunched = true; _invoiceUrlLaunched = true;
try { if (!mounted) return;
await launchUrl( final result = await Navigator.of(context).push<String>(
Uri.parse(url), MaterialPageRoute(
mode: LaunchMode.inAppBrowserView, // Custom Tab on Android, SFVC on iOS builder: (_) => XenditCheckoutScreen(
invoiceUrl: url,
paymentId: widget.paymentId,
),
),
); );
} catch (e) { // Pop result is mostly informational — the polling loop is still the
// Silent — polling will eventually resolve to expired if the customer can't pay. // source of truth for terminal status. We just trigger an immediate poll
// Don't surface an error toast; the user might have a non-Custom-Tab-capable env // so the user doesn't have to wait for the next 3s tick.
// and url_launcher falls back to the system browser automatically. if (!mounted) return;
if (result == 'success' || result == 'failure' || result == 'deeplink') {
_pollOnce();
} }
} }
@@ -261,52 +280,9 @@ class _WaitingPaymentScreenState extends ConsumerState<WaitingPaymentScreen>
borderRadius: HaloRadius.xl, borderRadius: HaloRadius.xl,
border: Border.all(color: HaloTokens.border), border: Border.all(color: HaloTokens.border),
), ),
child: Column( child: _invoiceUrl != null
children: [ ? _buildInvoiceCard(amount)
const Text( : _buildQrCard(amount),
'scan QRIS untuk bayar',
style: TextStyle(fontSize: 12, color: HaloTokens.inkSoft),
),
const SizedBox(height: HaloSpacing.s12),
Container(
padding: const EdgeInsets.all(HaloSpacing.s12),
decoration: BoxDecoration(
color: HaloTokens.surface,
borderRadius: HaloRadius.lg,
border: Border.all(color: HaloTokens.border),
),
child: QrImageView(
data: _qrPayload ?? widget.paymentId,
size: 200,
version: QrVersions.auto,
backgroundColor: HaloTokens.surface,
eyeStyle: const QrEyeStyle(
eyeShape: QrEyeShape.square,
color: HaloTokens.ink,
),
dataModuleStyle: const QrDataModuleStyle(
dataModuleShape: QrDataModuleShape.square,
color: HaloTokens.ink,
),
),
),
const SizedBox(height: HaloSpacing.s12),
const Text(
'jumlah',
style: TextStyle(fontSize: 11.5, color: HaloTokens.inkSoft),
),
Text(
formatRupiah(amount),
style: const TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 24,
fontWeight: FontWeight.w700,
color: HaloTokens.brandDark,
letterSpacing: -0.5,
),
),
],
),
), ),
const SizedBox(height: HaloSpacing.s12), const SizedBox(height: HaloSpacing.s12),
Container( Container(
@@ -353,4 +329,120 @@ class _WaitingPaymentScreenState extends ConsumerState<WaitingPaymentScreen>
], ],
); );
} }
/// Mock QRIS card — used when `xendit_invoice_url` is absent
/// (`XENDIT_ENABLED=false`, i.e. dev/Maestro).
Widget _buildQrCard(int amount) {
return Column(
children: [
const Text(
'scan QRIS untuk bayar',
style: TextStyle(fontSize: 12, color: HaloTokens.inkSoft),
),
const SizedBox(height: HaloSpacing.s12),
Container(
padding: const EdgeInsets.all(HaloSpacing.s12),
decoration: BoxDecoration(
color: HaloTokens.surface,
borderRadius: HaloRadius.lg,
border: Border.all(color: HaloTokens.border),
),
child: QrImageView(
data: _qrPayload ?? widget.paymentId,
size: 200,
version: QrVersions.auto,
backgroundColor: HaloTokens.surface,
eyeStyle: const QrEyeStyle(
eyeShape: QrEyeShape.square,
color: HaloTokens.ink,
),
dataModuleStyle: const QrDataModuleStyle(
dataModuleShape: QrDataModuleShape.square,
color: HaloTokens.ink,
),
),
),
const SizedBox(height: HaloSpacing.s12),
const Text(
'jumlah',
style: TextStyle(fontSize: 11.5, color: HaloTokens.inkSoft),
),
Text(
formatRupiah(amount),
style: const TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 24,
fontWeight: FontWeight.w700,
color: HaloTokens.brandDark,
letterSpacing: -0.5,
),
),
],
);
}
/// Spinner + "Buka ulang halaman pembayaran" — used when the backend
/// returned a Xendit invoice URL. The WebView screen is pushed
/// automatically on first load; the button lets the customer reopen it
/// if they accidentally closed it.
Widget _buildInvoiceCard(int amount) {
return Column(
children: [
const SizedBox(
width: 56,
height: 56,
child: CircularProgressIndicator(
strokeWidth: 3,
valueColor: AlwaysStoppedAnimation<Color>(HaloTokens.brand),
),
),
const SizedBox(height: HaloSpacing.s16),
const Text(
'Membuka halaman pembayaran…',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
color: HaloTokens.inkSoft,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: HaloSpacing.s12),
const Text(
'jumlah',
style: TextStyle(fontSize: 11.5, color: HaloTokens.inkSoft),
),
Text(
formatRupiah(amount),
style: const TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 24,
fontWeight: FontWeight.w700,
color: HaloTokens.brandDark,
letterSpacing: -0.5,
),
),
const SizedBox(height: HaloSpacing.s16),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () => _maybeOpenCheckoutScreen(force: true),
icon: const Icon(Icons.open_in_browser, size: 18),
label: const Text('Buka ulang halaman pembayaran'),
style: OutlinedButton.styleFrom(
foregroundColor: HaloTokens.brandDark,
side: const BorderSide(color: HaloTokens.brandSoft),
padding: const EdgeInsets.symmetric(vertical: HaloSpacing.s12),
shape: const RoundedRectangleBorder(
borderRadius: HaloRadius.md,
),
textStyle: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
),
),
],
);
}
} }

View File

@@ -0,0 +1,163 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:webview_flutter/webview_flutter.dart';
import '../../../core/theme/halo_tokens.dart';
/// Embedded WebView host for the Xendit hosted checkout page.
///
/// Why embedded (vs `LaunchMode.inAppBrowserView` / Custom Tab):
/// 1. We get full control over `NavigationDelegate`, so we can short-circuit
/// the `halobestie://` deeplink + the backend's `/payment/return/*` HTML
/// pages without round-tripping through the system browser intent
/// handler.
/// 2. Pop result tells the parent (`WaitingPaymentScreen`) to trigger an
/// immediate poll instead of waiting up to 3s for the next tick.
///
/// Pop results:
/// - `'success'` — URL matched `/payment/return/success`
/// - `'failure'` — URL matched `/payment/return/failure`
/// - `'deeplink'` — `halobestie://` scheme was followed (typically from the
/// return page's button; the existing intent-filter in
/// AndroidManifest would also handle it, but we intercept
/// here so the WebView host pops cleanly)
/// - `null` — user tapped the close (X) button manually
///
/// iOS NOTE: when shipping iOS, the dev backend at `http://192.168.88.247:3000`
/// will be blocked by ATS. Add an `NSAppTransportSecurity > NSAllowsArbitraryLoads`
/// (or per-domain `NSExceptionDomains`) entry to `ios/Runner/Info.plist` before
/// the iOS dev build will let the WebView load any /payment/return/* page from
/// the dev backend. Production (`api.halobestie.com`) is HTTPS so no exception
/// is needed there. Not touching Info.plist now — Android-only session.
class XenditCheckoutScreen extends ConsumerStatefulWidget {
final String invoiceUrl;
final String paymentId;
const XenditCheckoutScreen({
super.key,
required this.invoiceUrl,
required this.paymentId,
});
@override
ConsumerState<XenditCheckoutScreen> createState() =>
_XenditCheckoutScreenState();
}
class _XenditCheckoutScreenState extends ConsumerState<XenditCheckoutScreen> {
late final WebViewController _controller;
int _progress = 0;
// Pre-compiled regexes for the backend's return pages. The route is defined
// in backend `routes/payment.return.js` as `GET /payment/return/:status`
// where :status is 'success' or 'failure'. Match anywhere in the URL so we
// don't have to care about query strings, host, or scheme.
static final RegExp _successPattern =
RegExp(r'/payment/return/success(\?|$|/)');
static final RegExp _failurePattern =
RegExp(r'/payment/return/failure(\?|$|/)');
@override
void initState() {
super.initState();
_controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setBackgroundColor(HaloTokens.surface)
..setNavigationDelegate(
NavigationDelegate(
onProgress: (p) {
if (!mounted) return;
setState(() => _progress = p);
},
onNavigationRequest: _handleNavigationRequest,
onWebResourceError: (error) {
// Log only — Xendit pages routinely emit non-fatal resource errors
// (favicons, third-party trackers blocked by adblock-style rules,
// etc). Surfacing those as snackbars would be noisy.
if (kDebugMode) {
debugPrint(
'[XenditCheckoutScreen] WebResourceError '
'code=${error.errorCode} type=${error.errorType} '
'desc=${error.description} url=${error.url}',
);
}
},
),
)
..loadRequest(Uri.parse(widget.invoiceUrl));
}
NavigationDecision _handleNavigationRequest(NavigationRequest request) {
final url = request.url;
// 1. Deeplink scheme — pop with informational result. The Android intent
// filter would also catch this from a system-browser context, but
// inside a WebView the engine surfaces it here as a navigation, and
// if we don't block it the WebView shows an `ERR_UNKNOWN_URL_SCHEME`
// error page.
if (url.startsWith('halobestie://')) {
_popWith('deeplink');
return NavigationDecision.prevent;
}
// 2. Backend's success return page.
if (_successPattern.hasMatch(url)) {
_popWith('success');
return NavigationDecision.prevent;
}
// 3. Backend's failure return page.
if (_failurePattern.hasMatch(url)) {
_popWith('failure');
return NavigationDecision.prevent;
}
return NavigationDecision.navigate;
}
void _popWith(String result) {
if (!mounted) return;
Navigator.of(context).pop(result);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: HaloTokens.surface,
appBar: AppBar(
backgroundColor: HaloTokens.surface,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.close, color: HaloTokens.brandDark),
tooltip: 'Tutup',
onPressed: () => Navigator.of(context).pop(),
),
title: const Text(
'Pembayaran Xendit',
style: TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 16,
fontWeight: FontWeight.w700,
color: HaloTokens.brandDark,
),
),
centerTitle: true,
bottom: _progress < 100
? PreferredSize(
preferredSize: const Size.fromHeight(2),
child: LinearProgressIndicator(
value: _progress / 100.0,
minHeight: 2,
backgroundColor: HaloTokens.brandSofter,
valueColor: const AlwaysStoppedAnimation<Color>(
HaloTokens.brand,
),
),
)
: null,
),
body: WebViewWidget(controller: _controller),
);
}
}

View File

@@ -11,6 +11,7 @@ import 'core/auth/token_storage.dart';
import 'core/chat/active_session_notifier.dart'; import 'core/chat/active_session_notifier.dart';
import 'core/chat/chat_notifier.dart'; import 'core/chat/chat_notifier.dart';
import 'core/notifications/notification_service.dart'; import 'core/notifications/notification_service.dart';
import 'core/pairing/pairing_notifier.dart';
import 'core/theme/halo_theme.dart'; import 'core/theme/halo_theme.dart';
import 'firebase_options.dart'; import 'firebase_options.dart';
import 'router.dart'; import 'router.dart';
@@ -101,6 +102,17 @@ class _AppState extends ConsumerState<App> with WidgetsBindingObserver {
notifier.connectedSessionId != sessionId) { notifier.connectedSessionId != sessionId) {
notifier.connectIfNotConnected(sessionId); notifier.connectIfNotConnected(sessionId);
} }
// If pairing is still in a waiting state on resume, the `paired` push
// may have landed while we were backgrounded and the user came back
// via the app icon (no tap → no _navigateFromMessage). Force a fresh
// activeSession fetch so the listener below can advance the pairing
// state from the server's truth.
final pairingState = ref.read(pairingProvider);
if (pairingState is PairingSearchingData ||
pairingState is PairingTargetedWaitingData) {
// ignore: discarded_futures
ref.read(activeSessionProvider.notifier).refresh();
}
} }
} }
@@ -153,6 +165,25 @@ class _AppState extends ConsumerState<App> with WidgetsBindingObserver {
} }
if (_appPaused) return; if (_appPaused) return;
final sessionId = snapshot.sessionId; final sessionId = snapshot.sessionId;
// Recovery: if pairing is still in a waiting state (or stuck in
// ALREADY_ACTIVE error from a stale in-flight session) but the server
// already has us in an active session, the WS `paired` event was lost
// (FCM fallback fired and the customer didn't tap, OR we missed it
// mid-reconnect, OR the new chat-request was blocked by a pre-existing
// session that the mitra has since accepted). Advance the pairing state
// from the polled snapshot so the searching screen unsticks.
if (sessionId != null) {
final pairingState = ref.read(pairingProvider);
if (pairingState is PairingSearchingData ||
pairingState is PairingTargetedWaitingData ||
pairingState is PairingErrorData) {
// ignore: discarded_futures
ref.read(pairingProvider.notifier).applyPairedFromPush(
sessionId: sessionId,
mitraName: snapshot.mitraName,
);
}
}
if (sessionId != null && notifier.connectedSessionId != sessionId) { if (sessionId != null && notifier.connectedSessionId != sessionId) {
notifier.connectIfNotConnected(sessionId); notifier.connectIfNotConnected(sessionId);
} }
@@ -160,7 +191,26 @@ class _AppState extends ConsumerState<App> with WidgetsBindingObserver {
final router = ref.watch(routerProvider); final router = ref.watch(routerProvider);
NotificationService.initialize(router); NotificationService.initialize(
router,
onDataMessage: (data) {
// FCM `paired` arrived (likely because the backend's WS push
// couldn't find the customer's socket). Advance the pairing
// notifier so the searching screen navigates without requiring
// the user to tap the notification.
if (data['type'] == 'paired') {
final sessionId = data['session_id'] as String?;
if (sessionId == null) return;
final mitraName =
(data['mitra_display_name'] as String?) ?? 'Bestie';
// ignore: discarded_futures
ref.read(pairingProvider.notifier).applyPairedFromPush(
sessionId: sessionId,
mitraName: mitraName,
);
}
},
);
return MaterialApp.router( return MaterialApp.router(
title: 'Halo Bestie', title: 'Halo Bestie',

View File

@@ -14,6 +14,7 @@ import shared_preferences_foundation
import sign_in_with_apple import sign_in_with_apple
import sqflite_darwin import sqflite_darwin
import url_launcher_macos import url_launcher_macos
import webview_flutter_wkwebview
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
@@ -25,4 +26,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
SignInWithApplePlugin.register(with: registry.registrar(forPlugin: "SignInWithApplePlugin")) SignInWithApplePlugin.register(with: registry.registrar(forPlugin: "SignInWithApplePlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin"))
} }

View File

@@ -1412,6 +1412,38 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.3" version: "3.0.3"
webview_flutter:
dependency: "direct main"
description:
name: webview_flutter
sha256: a3da219916aba44947d3a5478b1927876a09781174b5a2b67fa5be0555154bf9
url: "https://pub.dev"
source: hosted
version: "4.13.1"
webview_flutter_android:
dependency: transitive
description:
name: webview_flutter_android
sha256: ad5182eff9a550925330cb9f0cb038eddfdd5712aba8b77aa0f0400e50f6e688
url: "https://pub.dev"
source: hosted
version: "4.12.0"
webview_flutter_platform_interface:
dependency: transitive
description:
name: webview_flutter_platform_interface
sha256: "1221c1b12f5278791042f2ec2841743784cf25c5a644e23d6680e5d718824f04"
url: "https://pub.dev"
source: hosted
version: "2.15.1"
webview_flutter_wkwebview:
dependency: transitive
description:
name: webview_flutter_wkwebview
sha256: "82648217f537573e1ca9ae9952d3eacedca6ab5aee69dc84445fc763766dcea2"
url: "https://pub.dev"
source: hosted
version: "3.25.1"
win32: win32:
dependency: transitive dependency: transitive
description: description:

View File

@@ -58,6 +58,12 @@ dependencies:
# WhatsApp / Telegram via https deeplinks from CC-config-driven handles. # WhatsApp / Telegram via https deeplinks from CC-config-driven handles.
url_launcher: ^6.3.0 url_launcher: ^6.3.0
# Embedded webview for Xendit hosted checkout — used by
# `features/payment/screens/xendit_checkout_screen.dart`. We host the
# invoice page inside the app (not a Custom Tab) so we can intercept the
# `halobestie://` deeplink + return-page URLs and pop deterministically.
webview_flutter: ^4.13.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter

View File

@@ -26,6 +26,20 @@
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
</intent-filter> </intent-filter>
</activity> </activity>
<!-- FCM default notification icon + tint color. Used by the
background-display path (when the OS, not the app, renders the
FCM notification). Foreground path overrides via
AndroidNotificationDetails in notification_service.dart.
ic_launcher_foreground is the adaptive icon's foreground layer
(the HaloBestie Mitra logo on transparent), tinted with the
color below. -->
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/ic_launcher_foreground" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_color"
android:resource="@color/ic_launcher_background" />
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data <meta-data

View File

@@ -1,5 +1,48 @@
package com.mybestie.mitra package com.mybestie.mitra
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.media.AudioAttributes
import android.net.Uri
import android.os.Build
import android.os.Bundle
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity() class MainActivity : FlutterActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
createNotificationChannel()
}
// See client_app MainActivity for the full why — short version:
// flutter_local_notifications' AndroidNotificationChannel sets AudioAttributes
// with CONTENT_TYPE_UNKNOWN, which Android 13+ silently treats as non-noisy
// for routing. We create the channel natively with CONTENT_TYPE_SONIFICATION
// so notification sound actually plays on API 33+.
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val channelId = "halobestie_chat_v2"
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (nm.getNotificationChannel(channelId) != null) return
val soundUri = Uri.parse("android.resource://$packageName/raw/halobestie_notif")
val audioAttributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
val channel = NotificationChannel(
channelId,
"Chat HaloBestie",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Notifications for incoming chat messages and extension requests"
setSound(soundUri, audioAttributes)
enableVibration(true)
enableLights(false)
}
nm.createNotificationChannel(channel)
}
}

View File

@@ -12,14 +12,15 @@ class NotificationService {
/// Set this from the app to bridge notifications → Riverpod state. /// Set this from the app to bridge notifications → Riverpod state.
static void Function(String sessionId)? onChatRequestTapped; static void Function(String sessionId)? onChatRequestTapped;
// Channel ID is bumped (`chat_messages` → `halobestie_chat_v1`) because // Channel ID bumped to v2 — v1 was created by flutter_local_notifications
// Android binds notification sound at channel-creation time on API 26+. An // with AudioAttributes content_type=CONTENT_TYPE_UNKNOWN, which Android 13+
// existing `chat_messages` channel still routes through whatever sound was // silently treats as non-noisy when routing — notifications post and are
// set when it was first created (system default), so we mint a fresh ID // tagged isNoisy=true, but systemui never requests audio focus and the
// for the branded sound. Backend FCM payloads target the same ID — see // sound never plays. v2 is created natively in MainActivity.kt with
// backend/src/services/notification.service.js. // CONTENT_TYPE_SONIFICATION so audio routing works on API 33+. Backend FCM
// payloads target this ID — see backend/src/services/notification.service.js.
static const _channel = AndroidNotificationChannel( static const _channel = AndroidNotificationChannel(
'halobestie_chat_v1', 'halobestie_chat_v2',
'Chat HaloBestie', 'Chat HaloBestie',
description: 'Notifications for incoming chat messages and extension requests', description: 'Notifications for incoming chat messages and extension requests',
importance: Importance.high, importance: Importance.high,
@@ -71,6 +72,12 @@ class NotificationService {
channelDescription: _channel.description, channelDescription: _channel.description,
importance: Importance.high, importance: Importance.high,
priority: Priority.high, priority: Priority.high,
// Foreground-path icon override. The OS will tint alpha-only
// content with the color set in AndroidManifest's
// default_notification_color meta-data. ic_launcher_foreground is
// the adaptive icon's foreground layer for the Mitra HaloBestie
// logo on transparent.
icon: 'ic_launcher_foreground',
// API 26+ ignores this in favor of the channel's sound; included for // API 26+ ignores this in favor of the channel's sound; included for
// the API 24/25 path where channels don't exist yet. // the API 24/25 path where channels don't exist yet.
sound: const RawResourceAndroidNotificationSound('halobestie_notif'), sound: const RawResourceAndroidNotificationSound('halobestie_notif'),