Files
halobestie-clone/client_app/lib/features/payment/screens/xendit_checkout_screen.dart
Ramadhan Sjamsani 3a0cdf5c4e 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>
2026-05-28 21:45:46 +08:00

164 lines
5.9 KiB
Dart

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