Files
halobestie-clone/client_app/lib/features/auth/screens/register_screen.dart
ramadhan sjamsani a09f37135c Phase 4 checkpoint: chat-screen perf refactor + retryable blast-failure + repo-wide dispose-ref guardrail
Chat-screen performance (customer + mitra):
- Parent screens have zero `ref.watch` — only `ref.listen` for side effects
- Body extracted into its own `ConsumerStatefulWidget`; AppBar parts split
  into narrow `.select` consumers (mode, sensitivity, timer)
- Per-second timer ticks routed to dedicated providers
  (`chatRemainingSecondsProvider` + new `mitraChatRemainingSecondsProvider`)
  so WS `session_tick` frames don't invalidate the rest of the chat state

Dispose-in-ref bug fix:
- `home_screen.dart`, `payment_screen.dart`, `mitra_chat_screen.dart` —
  ref-using cleanup moved from `dispose()` to `deactivate()`. Modern
  Riverpod invalidates `ref` the moment `dispose()` runs; the resulting
  silent error corrupts the widget-tree finalize and the next screen
  appears frozen
- `halo_lints` package added at repo root with `no_ref_in_dispose` rule
  to catch this pattern in CI / IDE analysis
- `custom_lint` activated in both apps' `analysis_options.yaml`
  (was installed but never wired in — also brings `riverpod_lint`'s
  `avoid_ref_inside_state_dispose` online)
- CLAUDE.md Pitfalls section added to client_app + mitra_app

Phase 4 §3 retryable blast-failure (Option A):
- Backend `expirePairingRequest` + all-rejected use
  `recordIntermediateFailure` instead of `failPaymentSession` so the
  payment session stays `confirmed` for re-blast
- WS `pairing_failed` payload carries `is_terminal: false` on the
  retryable paths; client parses the flag and exposes `retryBlast()`
- "Coba cari lagi" CTA on S7 Timeout now re-blasts on the same payment
- Pairing service test updated to reflect the new semantics

Customer waiting-payment screen navigation patch:
- `_navigateTerminal` uses `Future.microtask` + `addPostFrameCallback`
  redundancy after a release-mode bug where polling stopped but
  `context.go` never fired, leaving the screen visually stuck on
  "menunggu pembayaran"

See requirement/resume-2026-05-15.md for next-day pickup checklist
(mitra release rebuild + S21 Ultra install + retest is the gating item).

Bundles unrelated in-flight Phase 4 §2.x work that was already on disk
(ESP screen removal, USP one-time gate scaffolding, bestie-availability
public route, OTP service edits, Maestro flow tweaks) — kept together
to avoid a partial-rebase mess.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:12:34 +08:00

329 lines
12 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/auth/auth_notifier.dart';
import '../../../core/constants.dart';
import '../../../core/theme/halo_tokens.dart';
import '../../../core/theme/widgets/widgets.dart';
/// S3a — WhatsApp input screen.
///
/// Visual contract is `Figma/screens/onboarding.jsx::S3Phone`:
/// - HaloStepDots at the top (step 3 of 4: S2 Nama → S5 ESP → S5b USP → S3a)
/// - Personalised display-title `"nomor wa-mu, {name}?"`
/// - +62 prefix as static chip; user types only the trailing digits
/// - Privacy reassurance card
/// - Primary CTA `"kirim kode"` (disabled until ≥9 digits)
/// - Ghost link `"lanjut tanpa verifikasi (harga normal)"` → anonymous path
///
/// Two callers route here:
/// 1. New-user verified onboarding (USP → here) — auth state has the
/// anonymous display_name set by S2.
/// 2. SHome1st "masuk →" banner for returning-user recovery — auth state
/// is initial; the name greeting falls back to "kamu".
class RegisterScreen extends ConsumerStatefulWidget {
const RegisterScreen({super.key});
@override
ConsumerState<RegisterScreen> createState() => _RegisterScreenState();
}
class _RegisterScreenState extends ConsumerState<RegisterScreen> {
final _phoneController = TextEditingController();
ProviderSubscription<AsyncValue<AuthData>>? _authSub;
// Server-imposed lockout from /otp/request 429s. Backend embeds
// retry_after_seconds in the AuthErrorInfo so we can disable the CTA
// until the next slot opens.
int _lockoutSeconds = 0;
Timer? _lockoutTimer;
String? _errorMessage;
@override
void initState() {
super.initState();
_phoneController.addListener(() => setState(() {}));
_authSub = ref.listenManual<AsyncValue<AuthData>>(authProvider, (prev, next) {
if (!mounted) return;
final data = next.valueOrNull;
if (data is AuthOtpSentData) {
// go (replace) so re-submitting the form doesn't stack OtpScreens
// with leftover listeners.
context.go('/auth/otp', extra: _e164Phone());
return;
}
if (next is AsyncError) {
final err = next.error;
setState(() => _errorMessage = err.toString());
if (err is AuthErrorInfo &&
err.retryAfterSeconds != null &&
(err.code == 'OTP_COOLDOWN' ||
err.code == 'OTP_RATE_LIMIT_PHONE' ||
err.code == 'OTP_RATE_LIMIT_IP')) {
_startLockout(err.retryAfterSeconds!);
}
} else if (next is AsyncData) {
if (_errorMessage != null) setState(() => _errorMessage = null);
}
});
}
@override
void dispose() {
_authSub?.close();
_lockoutTimer?.cancel();
_phoneController.dispose();
super.dispose();
}
void _startLockout(int seconds) {
_lockoutTimer?.cancel();
setState(() => _lockoutSeconds = seconds);
_lockoutTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (!mounted) {
timer.cancel();
return;
}
setState(() {
if (_lockoutSeconds > 0) _lockoutSeconds--;
if (_lockoutSeconds <= 0) timer.cancel();
});
});
}
/// Subscriber digits with leading zeros stripped. Users commonly type
/// `0812…` (local format); the backend wants `+62812…`, so the 0 must go.
String _subscriberDigits() {
final digits = _phoneController.text.replaceAll(RegExp(r'\D'), '');
return digits.replaceFirst(RegExp(r'^0+'), '');
}
/// Local digits (no country code) → E.164 string the backend expects.
String _e164Phone() => '+62${_subscriberDigits()}';
String _greetingName(AuthData? data) => switch (data) {
AuthAnonymousData d => d.displayName,
AuthAuthenticatedData d => (d.profile['display_name'] as String?) ?? '',
AuthNeedsDisplayNameData d => (d.profile['display_name'] as String?) ?? '',
_ => '',
};
@override
Widget build(BuildContext context) {
final authState = ref.watch(authProvider);
final isLoading = authState is AsyncLoading;
final isLockedOut = _lockoutSeconds > 0;
final hasMinDigits = _subscriberDigits().length >= 9;
final canSubmit = hasMinDigits && !isLoading && !isLockedOut;
final name = _greetingName(authState.valueOrNull);
final shownName = name.isEmpty ? 'kamu' : name;
return Scaffold(
backgroundColor: HaloTokens.bg,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(28, 8, 28, 28),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Padding(
padding: EdgeInsets.only(top: 8, bottom: 8),
child: HaloStepDots(total: 4, current: 3),
),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'nomor wa-mu, $shownName?',
style: const TextStyle(
fontFamily: HaloTokens.fontDisplay,
fontSize: 28,
fontWeight: FontWeight.w700,
color: HaloTokens.brandDark,
height: 1.15,
letterSpacing: -0.56,
),
),
const SizedBox(height: 10),
const Text(
'supaya bisa lanjut kapan aja, dan dapat harga khusus pengguna baru.',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 14.5,
color: HaloTokens.inkSoft,
height: 1.5,
),
),
const SizedBox(height: 24),
_PhoneRow(
controller: _phoneController,
borderColor: hasMinDigits
? HaloTokens.brand
: HaloTokens.border,
),
const SizedBox(height: 16),
const _PrivacyCard(),
if (_errorMessage != null) ...[
const SizedBox(height: 12),
Text(
_errorMessage!,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
color: HaloTokens.danger,
fontSize: 13,
),
),
],
],
),
),
HaloButton(
label: isLoading
? 'memproses...'
: isLockedOut
? 'coba lagi dalam ${formatCountdown(_lockoutSeconds)}'
: 'kirim kode',
fullWidth: true,
onPressed: canSubmit
? () => ref
.read(authProvider.notifier)
.requestOtp(_e164Phone())
: null,
),
const SizedBox(height: 4),
TextButton(
onPressed: isLoading
? null
// Skip ESPb/USPb — the verified branch already ran ESPa+USPa,
// so the redirect alias drops the user straight at PickMethod.
: () => context.go('/onboarding/anon/method'),
style: TextButton.styleFrom(
foregroundColor: HaloTokens.inkSoft,
minimumSize: const Size(0, 40),
),
child: const Text(
'lanjut tanpa verifikasi (harga normal)',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 12.5,
decoration: TextDecoration.underline,
decorationColor: HaloTokens.inkSoft,
),
),
),
],
),
),
),
);
}
}
class _PhoneRow extends StatelessWidget {
final TextEditingController controller;
final Color borderColor;
const _PhoneRow({required this.controller, required this.borderColor});
@override
Widget build(BuildContext context) {
return Container(
height: 60,
padding: const EdgeInsets.symmetric(horizontal: 18),
decoration: BoxDecoration(
color: HaloTokens.surface,
borderRadius: HaloRadius.lg,
border: Border.all(color: borderColor, width: 1.5),
),
child: Row(
children: [
const Text(
'🇮🇩 +62',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 15,
fontWeight: FontWeight.w600,
color: HaloTokens.ink,
),
),
const SizedBox(width: 12),
Expanded(
child: TextField(
controller: controller,
keyboardType: TextInputType.phone,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
maxLength: 13, // enough headroom for 12-digit ID mobiles
style: const TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 16,
fontWeight: FontWeight.w500,
color: HaloTokens.ink,
),
decoration: const InputDecoration(
hintText: '812 3456 7890',
hintStyle: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 16,
fontWeight: FontWeight.w500,
color: HaloTokens.inkMuted,
),
// Override the app-wide inputDecorationTheme so the input
// sits flush inside the outer pill — no fill, no border.
filled: false,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
errorBorder: InputBorder.none,
focusedErrorBorder: InputBorder.none,
disabledBorder: InputBorder.none,
isCollapsed: true,
counterText: '',
contentPadding: EdgeInsets.symmetric(vertical: 18),
),
),
),
],
),
);
}
}
class _PrivacyCard extends StatelessWidget {
const _PrivacyCard();
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: HaloTokens.brandSofter,
borderRadius: HaloRadius.md,
border: Border.all(color: HaloTokens.brandSoft),
),
child: const Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('🛡️', style: TextStyle(fontSize: 14)),
SizedBox(width: 10),
Expanded(
child: Text(
'anonim — bestie cuma tau nama panggilan kamu. nomor gak akan dishare.',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 12,
color: HaloTokens.brandDark,
height: 1.45,
),
),
),
],
),
);
}
}