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:
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user