diff --git a/.dev/wsl_emulator_bridge.ps1 b/.dev/wsl_emulator_bridge.ps1 new file mode 100644 index 0000000..60a2702 --- /dev/null +++ b/.dev/wsl_emulator_bridge.ps1 @@ -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 → 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 + } +} diff --git a/.dev/wsl_tcp_relay.py b/.dev/wsl_tcp_relay.py new file mode 100644 index 0000000..a55d414 --- /dev/null +++ b/.dev/wsl_tcp_relay.py @@ -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 + Relays 127.0.0.1: -> :. + + dynamic: wsl_tcp_relay.py --watch-adb + 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: -> : 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: 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()) diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 45ff767..a689cad 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -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 -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 -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. diff --git a/backend/src/services/notification.service.js b/backend/src/services/notification.service.js index f59b5b3..753aa5b 100644 --- a/backend/src/services/notification.service.js +++ b/backend/src/services/notification.service.js @@ -36,7 +36,7 @@ export const sendPushNotification = async (recipientType, recipientId, { title, // 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' }, + notification: { channelId: 'halobestie_chat_v2' }, }, apns: { payload: { diff --git a/backend/src/services/payment.service.js b/backend/src/services/payment.service.js index 5630b32..688bb72 100644 --- a/backend/src/services/payment.service.js +++ b/backend/src/services/payment.service.js @@ -26,6 +26,7 @@ // // on(eventName, handler) → subscribe to lifecycle events // 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 // recordIntermediateFailure(...) → audit-only failure for flows with a fallback path @@ -87,8 +88,54 @@ const 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 invoiceMethod = xenditInvoiceMethodFromCode(preferredPaymentCode) + console.log('[xendit] createInvoice', { paymentRequestId, amount, preferredPaymentCode, invoiceMethod }) const inv = await xenditClient().Invoice.createInvoice({ data: { 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 // purely from the echoed payload. Keep this string stable — it is a routing key. 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 } @@ -177,10 +227,10 @@ export const requestPayment = async ({ isExtension = false, targetedMitraId = null, // Customer's pre-picked payment method from the catalog. Optional; - // upper-cased Xendit channel code (e.g. `OVO`). Stamped onto - // product_metadata for analytics + future use as a Xendit `paymentMethods` - // filter. Not currently passed to Xendit invoice creation — the customer - // re-picks on Xendit's checkout page. + // upper-cased channel code (e.g. `OVO`, `BCA_VIRTUAL_ACCOUNT`, `CARDS`). + // Stamped onto product_metadata for analytics AND translated via + // xenditInvoiceMethodFromCode() into Xendit's invoice paymentMethods[] + // filter so the customer doesn't have to re-pick on the hosted page. preferredPaymentCode = null, }) => { if (!customerId) { @@ -239,6 +289,7 @@ export const requestPayment = async ({ amount: row.amount, ttlMinutes: ttl, description: buildInvoiceDescription(row), + preferredPaymentCode, }) await sql` UPDATE payment_requests diff --git a/backend/test/services/payment.service.test.js b/backend/test/services/payment.service.test.js index b6cb27d..45b3d50 100644 --- a/backend/test/services/payment.service.test.js +++ b/backend/test/services/payment.service.test.js @@ -4,6 +4,7 @@ import { confirmPaymentSession, getPaymentSession, getCustomerPendingPayments, + xenditInvoiceMethodFromCode, } from '../../src/services/payment.service.js' import { PaymentRequestStatus, SessionStatus } from '../../src/constants.js' import { resetDb, resetAppConfig, db } from '../helpers/db.js' @@ -85,6 +86,45 @@ describe('payment.service', () => { 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. describe('getCustomerPendingPayments', () => { it('returns empty when customer has no payments', async () => { diff --git a/client_app/.maestro/flows/ts-customer-06-01-end_session_via_timeup_sheet.yaml b/client_app/.maestro/flows/ts-customer-06-01-end_session_via_timeup_sheet.yaml new file mode 100644 index 0000000..ae594ef --- /dev/null +++ b/client_app/.maestro/flows/ts-customer-06-01-end_session_via_timeup_sheet.yaml @@ -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 diff --git a/client_app/.maestro/subflows/onboarding_returning_user.yaml b/client_app/.maestro/subflows/onboarding_returning_user.yaml index 69b863f..5978747 100644 --- a/client_app/.maestro/subflows/onboarding_returning_user.yaml +++ b/client_app/.maestro/subflows/onboarding_returning_user.yaml @@ -14,11 +14,13 @@ # - NODE_ENV != 'production' on backend (so /internal/_test routes exist). # # Path taken: -# Welcome carousel ("Mulai") → Home with anon banner → -# tap "masuk →" → /auth/register → enter +62 subscriber digits → -# "kirim kode" → OTP screen → peek OTP from stub → auto-submit → -# /auth/set-name (because AuthNeedsDisplayNameData) → enter "Maestro" → -# "Lanjut" → /home (returning view: "curhatan sebelumnya" header). +# Splash auto-advances (2026-05-26 — the 3-page welcome carousel was +# removed; splash self-drives to /home via context.go after ~2.5s) → +# Home with anon banner → tap "masuk →" → /auth/register → enter +62 +# subscriber digits → "kirim kode" → OTP screen → peek OTP from stub → +# 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 # tappable parent into ONE accessibility blob. Maestro's `text:` selector @@ -28,15 +30,8 @@ # inside the field's pill before typing. appId: ${APP_ID_ANDROID} --- -# Welcome carousel — the "Mulai" button is the only Text inside its -# tappable region, so a plain selector works. -- extendedWaitUntil: - visible: - text: "Mulai" - timeout: 15000 -- tapOn: - text: "Mulai" - retryTapIfNoChange: true +# Splash self-navigates to /home; nothing for the test to tap. Move +# straight to the home-screen anon banner assertion. # Home — login-recover banner ("udah pernah pakai HaloBestie? … masuk →") # is one InkWell, so any token within its merged blob reaches the @@ -56,12 +51,21 @@ appId: ${APP_ID_ANDROID} timeout: 10000 # Phone input — +62 is a static prefix chip; type only subscriber digits -# (no leading 0). +6281234567890 → 81234567890. Tap by point inside the -# pill (well right of the +62 chip, well above the kirim-kode button). +# (no leading 0). +6281234567890 → 81234567890. +# +# 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: - point: "60%, 47%" + text: "(?s).*812 3456 7890.*" - inputText: "81234567890" -- hideKeyboard - tapOn: text: "(?s).*kirim kode.*" diff --git a/client_app/android/app/src/main/AndroidManifest.xml b/client_app/android/app/src/main/AndroidManifest.xml index affab01..1e3102b 100644 --- a/client_app/android/app/src/main/AndroidManifest.xml +++ b/client_app/android/app/src/main/AndroidManifest.xml @@ -42,6 +42,20 @@ + + + + ?>? _inFlightRefresh; + ApiClient get _apiClient => ref.read(apiClientProvider); AuthBridge get _bridge => ref.read(authBridgeProvider); @@ -96,7 +104,12 @@ class Auth extends _$Auth { // ---------------- Refresh / bootstrap ---------------- - Future?> _refreshFromStorage() async { + Future?> _refreshFromStorage() { + return _inFlightRefresh ??= _doRefreshFromStorage() + .whenComplete(() => _inFlightRefresh = null); + } + + Future?> _doRefreshFromStorage() async { final refreshToken = await _storage.readRefreshToken(); if (refreshToken == null) return null; @@ -106,7 +119,7 @@ class Auth extends _$Auth { data: {'refresh_token': refreshToken}, skipAuth: true, ); - return _applyTokens(response); + return await _applyTokens(response); } catch (_) { await _storage.clear(); _bridge.clear(); diff --git a/client_app/lib/core/notifications/notification_service.dart b/client_app/lib/core/notifications/notification_service.dart index ca09662..ff1e9fa 100644 --- a/client_app/lib/core/notifications/notification_service.dart +++ b/client_app/lib/core/notifications/notification_service.dart @@ -7,22 +7,46 @@ import 'package:go_router/go_router.dart'; class NotificationService { static final _localNotifications = FlutterLocalNotificationsPlugin(); static GoRouter? _router; + static void Function(Map data)? _onDataMessage; + static bool _initialized = false; - // 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. + // Channel ID bumped to v2 — v1 was created by flutter_local_notifications + // with AudioAttributes content_type=CONTENT_TYPE_UNKNOWN (the plugin's + // AndroidNotificationChannel doesn't expose contentType), which Android 13+ + // silently treats as non-noisy when routing — notifications post and are + // 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( - 'halobestie_chat_v1', + 'halobestie_chat_v2', 'Chat HaloBestie', description: 'Notifications for incoming chat messages and pairing requests', importance: Importance.high, sound: RawResourceAndroidNotificationSound('halobestie_notif'), ); - static Future 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 initialize( + GoRouter router, { + void Function(Map data)? onDataMessage, + }) async { _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 await _localNotifications @@ -52,9 +76,19 @@ class NotificationService { } 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; 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( id: notification.hashCode, title: notification.title, @@ -66,6 +100,12 @@ class NotificationService { channelDescription: _channel.description, importance: Importance.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 // for the API 24/25 path where channels don't exist yet. sound: const RawResourceAndroidNotificationSound('halobestie_notif'), @@ -93,6 +133,10 @@ class NotificationService { } static void _navigateFromMessage(Map 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; final sessionId = data['session_id'] as String?; final type = data['type'] as String?; diff --git a/client_app/lib/core/pairing/pairing_notifier.dart b/client_app/lib/core/pairing/pairing_notifier.dart index eefb2a5..784851a 100644 --- a/client_app/lib/core/pairing/pairing_notifier.dart +++ b/client_app/lib/core/pairing/pairing_notifier.dart @@ -186,7 +186,17 @@ class Pairing extends _$Pairing { paymentRequestId: paymentRequestId, ); } 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 { state = const PairingErrorData('Gagal memulai. Coba lagi.'); } @@ -322,6 +332,43 @@ class Pairing extends _$Pairing { 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 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 /// `confirmed` (retryable failure). Re-blasts on the same payment session. /// diff --git a/client_app/lib/features/chat/screens/chat_screen.dart b/client_app/lib/features/chat/screens/chat_screen.dart index adedf48..475a603 100644 --- a/client_app/lib/features/chat/screens/chat_screen.dart +++ b/client_app/lib/features/chat/screens/chat_screen.dart @@ -10,6 +10,9 @@ import '../../../core/theme/halo_tokens.dart'; import '../../../core/theme/widgets/halo_snackbar.dart'; import '../widgets/bestie_unavailable_dialog.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'; /// S10 Chat Room — strict Figma implementation (Phase 4, 2026-05-12). @@ -509,7 +512,14 @@ class _TimerBanner extends ConsumerWidget { return _BannerKind.none; })); 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) { 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 _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 ────────── class _ChatHeader extends ConsumerStatefulWidget { diff --git a/client_app/lib/features/chat/widgets/chat_expired_banner.dart b/client_app/lib/features/chat/widgets/chat_expired_banner.dart index fb594e8..7bfc9c3 100644 --- a/client_app/lib/features/chat/widgets/chat_expired_banner.dart +++ b/client_app/lib/features/chat/widgets/chat_expired_banner.dart @@ -57,22 +57,35 @@ class ChatExpiredBanner extends StatelessWidget { ), ), const SizedBox(width: 8), - ElevatedButton( - onPressed: onExtend, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: HaloTokens.brand, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(999)), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - minimumSize: const Size(0, 32), - elevation: 0, - textStyle: const TextStyle( - fontFamily: HaloTokens.fontBody, - fontSize: 11.5, - fontWeight: FontWeight.w700, + // 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, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: HaloTokens.brand, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(999)), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + minimumSize: const Size(0, 32), + elevation: 0, + textStyle: const TextStyle( + fontFamily: HaloTokens.fontBody, + fontSize: 11.5, + fontWeight: FontWeight.w700, + ), ), + child: const Text('perpanjang'), ), - child: const Text('perpanjang'), ), ], ), diff --git a/client_app/lib/features/chat/widgets/pricing_bottom_sheet.dart b/client_app/lib/features/chat/widgets/pricing_bottom_sheet.dart index e7baf1b..3a38204 100644 --- a/client_app/lib/features/chat/widgets/pricing_bottom_sheet.dart +++ b/client_app/lib/features/chat/widgets/pricing_bottom_sheet.dart @@ -21,21 +21,42 @@ class PricingBottomSheet extends ConsumerStatefulWidget { /// Required — the in-progress chat session id this extension targets. 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). - static Future showForExtension(BuildContext context, {required String sessionId}) { + static Future showForExtension( + BuildContext context, { + required String sessionId, + VoidCallback? onEndSession, + }) { return showModalBottomSheet( context: context, isScrollControlled: true, 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( borderRadius: BorderRadius.only( topLeft: 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 { ); } + void _onEndSession() { + Navigator.of(context).pop(); + widget.onEndSession?.call(); + } + @override Widget build(BuildContext context) { final pricingAsync = ref.watch(chatPricingProvider); @@ -105,6 +131,7 @@ class _PricingBottomSheetState extends ConsumerState { }), onTierTap: _onTierTap, onConfirm: _onConfirm, + onEndSession: widget.onEndSession != null ? _onEndSession : null, ), ), ); @@ -122,6 +149,7 @@ class _Body extends StatelessWidget { final ValueChanged onModeChanged; final ValueChanged onTierTap; final ValueChanged onConfirm; + final VoidCallback? onEndSession; const _Body({ required this.pricing, @@ -132,6 +160,7 @@ class _Body extends StatelessWidget { required this.onModeChanged, required this.onTierTap, required this.onConfirm, + required this.onEndSession, }); @override @@ -206,21 +235,35 @@ 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( HaloSpacing.s24, - HaloSpacing.s8, - HaloSpacing.s24, + HaloSpacing.s12, HaloSpacing.s24, + HaloSpacing.s16, ), - decoration: const BoxDecoration( - border: Border(top: BorderSide(color: HaloTokens.border)), - ), - child: HaloButton( - label: ctaLabel, - size: HaloButtonSize.lg, - fullWidth: true, - onPressed: hasSelection ? () => onConfirm(selectedTier) : null, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + HaloButton( + label: ctaLabel, + size: HaloButtonSize.lg, + fullWidth: true, + 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, + ), + ], + ], ), ), ], diff --git a/client_app/lib/features/home/providers/bestie_history_provider.dart b/client_app/lib/features/home/providers/bestie_history_provider.dart index 96e646f..8e93065 100644 --- a/client_app/lib/features/home/providers/bestie_history_provider.dart +++ b/client_app/lib/features/home/providers/bestie_history_provider.dart @@ -10,6 +10,10 @@ class BestieHistoryItem { final List topics; final int sessionsCount; 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({ required this.sessionId, @@ -19,6 +23,7 @@ class BestieHistoryItem { required this.topics, required this.sessionsCount, required this.mitraIsOnline, + required this.status, }); factory BestieHistoryItem.fromJson(Map json) { @@ -38,6 +43,7 @@ class BestieHistoryItem { _ => 1, }, mitraIsOnline: json['mitra_is_online'] as bool? ?? false, + status: json['status'] as String?, ); } } diff --git a/client_app/lib/features/home/screens/bestie_history_list_screen.dart b/client_app/lib/features/home/screens/bestie_history_list_screen.dart index 1befba6..a05cb30 100644 --- a/client_app/lib/features/home/screens/bestie_history_list_screen.dart +++ b/client_app/lib/features/home/screens/bestie_history_list_screen.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import '../../../core/api/api_client_provider.dart'; import '../../../core/constants.dart'; import '../../../core/theme/halo_tokens.dart'; import '../../../core/theme/widgets/widgets.dart'; @@ -36,7 +35,6 @@ class BestieHistoryListScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final historyAsync = ref.watch(bestieHistoryProvider); - final rawAsync = ref.watch(_rawHistoryProvider); return Scaffold( backgroundColor: HaloTokens.bg, @@ -81,7 +79,6 @@ class BestieHistoryListScreen extends ConsumerWidget { return RefreshIndicator( onRefresh: () async { ref.invalidate(bestieHistoryProvider); - ref.invalidate(_rawHistoryProvider); await ref.read(bestieHistoryProvider.future); }, child: ListView.separated( @@ -110,8 +107,7 @@ class BestieHistoryListScreen extends ConsumerWidget { ); } final item = items[index - 1]; - final raw = rawAsync.valueOrNull?[index - 1]; - final isClosing = raw?['status'] == SessionStatus.closing; + final isClosing = item.status == SessionStatus.closing; return _BestieRow( item: item, 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>>((ref) async { - final api = ref.read(apiClientProvider); - final response = await api.get('/api/client/chat/history'); - return ((response['data']['items'] as List?) ?? const []) - .cast>(); -}); - class _BestieRow extends StatelessWidget { final BestieHistoryItem item; final bool isClosing; diff --git a/client_app/lib/features/payment/screens/method_pick_screen.dart b/client_app/lib/features/payment/screens/method_pick_screen.dart index c78195c..b75c0a8 100644 --- a/client_app/lib/features/payment/screens/method_pick_screen.dart +++ b/client_app/lib/features/payment/screens/method_pick_screen.dart @@ -19,6 +19,11 @@ class MethodPickScreen extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { return Scaffold( 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( backgroundColor: HaloTokens.bg, elevation: 0, diff --git a/client_app/lib/features/payment/screens/waiting_payment_screen.dart b/client_app/lib/features/payment/screens/waiting_payment_screen.dart index cac3a58..7215365 100644 --- a/client_app/lib/features/payment/screens/waiting_payment_screen.dart +++ b/client_app/lib/features/payment/screens/waiting_payment_screen.dart @@ -4,11 +4,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:qr_flutter/qr_flutter.dart'; -import 'package:url_launcher/url_launcher.dart'; import '../../../core/api/api_client_provider.dart'; import '../../../core/constants.dart'; import '../../../core/theme/halo_tokens.dart'; import '../state/payment_draft_provider.dart'; +import 'xendit_checkout_screen.dart'; /// "Waiting payment" — placeholder QR + 20-minute countdown header. Polls the /// backend every 3 seconds for status changes. On `confirmed` the payment is @@ -34,10 +34,17 @@ class _WaitingPaymentScreenState extends ConsumerState DateTime? _expiresAt; int _amount = 0; 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 _terminal = false; 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 { final exp = _expiresAt; @@ -76,37 +83,49 @@ class _WaitingPaymentScreenState extends ConsumerState if (!mounted || session == null) return; final expiresAtRaw = session['expires_at'] as String?; final expiresAt = expiresAtRaw != null ? DateTime.tryParse(expiresAtRaw) : null; + final invoiceUrl = + (session['xendit_invoice_url'] as String?) ?? (session['invoice_url'] as String?); setState(() { _amount = (session['amount'] as int?) ?? 0; _expiresAt = expiresAt; _qrPayload = (session['qr_string'] as String?) ?? widget.paymentId; + _invoiceUrl = (invoiceUrl != null && invoiceUrl.isNotEmpty) ? invoiceUrl : null; _initialLoading = false; }); - // Phase 5: when Xendit is on, the backend returns an `xendit_invoice_url` - // (Xendit's hosted checkout). Open it in a Custom Tab (Android) / - // SFSafariViewController (iOS) so the customer stays inside the app's - // browser context. Fire-and-forget — polling continues regardless. - // When Xendit is off (dev/Maestro), invoice_url is null and the QR fallback below is used. - await _maybeLaunchInvoiceUrl(session); + // Phase 5: when Xendit is on, host the invoice page inside the app via + // an embedded WebView (XenditCheckoutScreen). Auto-push once on first + // load; the "Buka ulang halaman pembayaran" button re-pushes manually if + // the customer accidentally closed it. When Xendit is off (dev/Maestro), + // _invoiceUrl is null and the QR fallback is rendered instead. + _maybeOpenCheckoutScreen(); _maybeHandleStatus(session); _startTicker(); _resumePolling(); } - Future _maybeLaunchInvoiceUrl(Map session) async { - if (_invoiceUrlLaunched) return; - final url = (session['xendit_invoice_url'] as String?) ?? (session['invoice_url'] as String?); - if (url == null || url.isEmpty) return; + /// Opens the embedded Xendit WebView. Auto-called once on first load when + /// `_invoiceUrl` is non-null; also wired to the "Buka ulang halaman + /// pembayaran" button (which forces a re-push regardless of the flag). + Future _maybeOpenCheckoutScreen({bool force = false}) async { + final url = _invoiceUrl; + if (url == null) return; + if (!force && _invoiceUrlLaunched) return; _invoiceUrlLaunched = true; - try { - await launchUrl( - Uri.parse(url), - mode: LaunchMode.inAppBrowserView, // Custom Tab on Android, SFVC on iOS - ); - } catch (e) { - // Silent — polling will eventually resolve to expired if the customer can't pay. - // Don't surface an error toast; the user might have a non-Custom-Tab-capable env - // and url_launcher falls back to the system browser automatically. + if (!mounted) return; + final result = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => XenditCheckoutScreen( + invoiceUrl: url, + paymentId: widget.paymentId, + ), + ), + ); + // Pop result is mostly informational — the polling loop is still the + // source of truth for terminal status. We just trigger an immediate poll + // so the user doesn't have to wait for the next 3s tick. + if (!mounted) return; + if (result == 'success' || result == 'failure' || result == 'deeplink') { + _pollOnce(); } } @@ -261,52 +280,9 @@ class _WaitingPaymentScreenState extends ConsumerState borderRadius: HaloRadius.xl, border: Border.all(color: HaloTokens.border), ), - child: 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, - ), - ), - ], - ), + child: _invoiceUrl != null + ? _buildInvoiceCard(amount) + : _buildQrCard(amount), ), const SizedBox(height: HaloSpacing.s12), Container( @@ -353,4 +329,120 @@ class _WaitingPaymentScreenState extends ConsumerState ], ); } + + /// 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(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, + ), + ), + ), + ), + ], + ); + } } diff --git a/client_app/lib/features/payment/screens/xendit_checkout_screen.dart b/client_app/lib/features/payment/screens/xendit_checkout_screen.dart new file mode 100644 index 0000000..fdc0bd4 --- /dev/null +++ b/client_app/lib/features/payment/screens/xendit_checkout_screen.dart @@ -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 createState() => + _XenditCheckoutScreenState(); +} + +class _XenditCheckoutScreenState extends ConsumerState { + 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( + HaloTokens.brand, + ), + ), + ) + : null, + ), + body: WebViewWidget(controller: _controller), + ); + } +} diff --git a/client_app/lib/main.dart b/client_app/lib/main.dart index 3e5b839..5fb9bdc 100644 --- a/client_app/lib/main.dart +++ b/client_app/lib/main.dart @@ -11,6 +11,7 @@ import 'core/auth/token_storage.dart'; import 'core/chat/active_session_notifier.dart'; import 'core/chat/chat_notifier.dart'; import 'core/notifications/notification_service.dart'; +import 'core/pairing/pairing_notifier.dart'; import 'core/theme/halo_theme.dart'; import 'firebase_options.dart'; import 'router.dart'; @@ -101,6 +102,17 @@ class _AppState extends ConsumerState with WidgetsBindingObserver { notifier.connectedSessionId != 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 with WidgetsBindingObserver { } if (_appPaused) return; 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) { notifier.connectIfNotConnected(sessionId); } @@ -160,7 +191,26 @@ class _AppState extends ConsumerState with WidgetsBindingObserver { 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( title: 'Halo Bestie', diff --git a/client_app/macos/Flutter/GeneratedPluginRegistrant.swift b/client_app/macos/Flutter/GeneratedPluginRegistrant.swift index 61cff3f..a5136ad 100644 --- a/client_app/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/client_app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -14,6 +14,7 @@ import shared_preferences_foundation import sign_in_with_apple import sqflite_darwin import url_launcher_macos +import webview_flutter_wkwebview func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) @@ -25,4 +26,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { SignInWithApplePlugin.register(with: registry.registrar(forPlugin: "SignInWithApplePlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) + WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin")) } diff --git a/client_app/pubspec.lock b/client_app/pubspec.lock index 9b42a5d..72a2331 100644 --- a/client_app/pubspec.lock +++ b/client_app/pubspec.lock @@ -1412,6 +1412,38 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: diff --git a/client_app/pubspec.yaml b/client_app/pubspec.yaml index 4c81b63..c193aed 100644 --- a/client_app/pubspec.yaml +++ b/client_app/pubspec.yaml @@ -58,6 +58,12 @@ dependencies: # WhatsApp / Telegram via https deeplinks from CC-config-driven handles. 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: flutter_test: sdk: flutter diff --git a/mitra_app/android/app/src/main/AndroidManifest.xml b/mitra_app/android/app/src/main/AndroidManifest.xml index f0e0cbc..907059e 100644 --- a/mitra_app/android/app/src/main/AndroidManifest.xml +++ b/mitra_app/android/app/src/main/AndroidManifest.xml @@ -26,6 +26,20 @@ + + + +