import 'dart:async'; import 'package:auto_route/auto_route.dart'; import 'package:dartz/dartz.dart' hide State; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_spinkit/flutter_spinkit.dart'; import '../../../application/auth/auth_bloc.dart'; import '../../../application/pin/pin_setup_form/pin_setup_form_bloc.dart'; import '../../../common/theme/theme.dart'; import '../../../domain/wallet/wallet.dart'; import '../../../injection.dart'; import '../../components/button/button.dart'; import '../../components/toast/flushbar.dart'; import '../wallet/wallet_ui.dart'; import 'pin_rules.dart'; import 'widgets/pin_code_field.dart'; enum _Step { otp, pin, confirm, done } /// Buat PIN (§6.2) atau lupa PIN (§6.4). Pop dengan `true` bila berhasil. @RoutePage() class PinSetupPage extends StatefulWidget implements AutoRouteWrapper { const PinSetupPage({super.key, required this.purpose}); final PinOtpPurpose purpose; @override State createState() => _PinSetupPageState(); @override Widget wrappedRoute(BuildContext context) => BlocProvider( create: (_) => getIt()..add(PinSetupFormEvent.otpRequested(purpose)), child: this, ); } class _PinSetupPageState extends State { final TextEditingController _otpController = TextEditingController(); final TextEditingController _pinController = TextEditingController(); _Step _step = _Step.otp; String _otpCode = ''; String _pin = ''; String? _errorText; Timer? _ticker; bool get _isReset => widget.purpose == PinOtpPurpose.reset; @override void initState() { super.initState(); _ticker = Timer.periodic(const Duration(seconds: 1), (_) { if (mounted) setState(() {}); }); } @override void dispose() { _ticker?.cancel(); _otpController.dispose(); _pinController.dispose(); _pin = ''; super.dispose(); } void _goTo(_Step step, {String? error}) { setState(() { _step = step; _errorText = error; }); _pinController.clear(); } void _onOtpCompleted(String code) { _otpCode = code; _goTo(_Step.pin); } void _onPinCompleted(String value) { if (_step == _Step.pin) { final error = validateNewPin( value, birthDate: context.read().state.user.birthDate, ); if (error != null) { _goTo(_Step.pin, error: error); return; } _pin = value; _goTo(_Step.confirm); return; } if (value != _pin) { _pin = ''; _goTo(_Step.pin, error: 'PIN tidak sama. Silakan ulangi.'); return; } context.read().add( PinSetupFormEvent.submitted( otpCode: _otpCode, pin: _pin, confirmPin: value, ), ); _pinController.clear(); } void _onSubmitResult(Either either) { either.fold( (f) { _pin = ''; f.maybeMap( rejected: (_) { _otpController.clear(); _goTo( _Step.otp, error: 'Kode OTP salah atau sudah kedaluwarsa, atau PIN tidak ' 'memenuhi syarat. Silakan ulangi.', ); }, orElse: () { _goTo(_Step.pin); AppFlushbar.showError(context, walletFailureMessage(f)); }, ); }, (_) { _pin = ''; _goTo(_Step.done); }, ); } @override Widget build(BuildContext context) { return BlocConsumer( listenWhen: (p, c) => p.failureOrSuccessOption != c.failureOrSuccessOption || p.otpFailureOption != c.otpFailureOption, listener: (context, state) { state.failureOrSuccessOption.fold(() {}, _onSubmitResult); state.otpFailureOption.fold(() {}, (f) { final isRateLimited = f.maybeMap( tooManyRequests: (_) => true, orElse: () => false, ); if (!isRateLimited) { AppFlushbar.showError(context, walletFailureMessage(f)); } }); }, builder: (context, state) { return PopScope( canPop: _step != _Step.done, onPopInvokedWithResult: (didPop, _) { if (!didPop && _step == _Step.done) Navigator.of(context).pop(true); }, child: Scaffold( appBar: AppBar(title: Text(_isReset ? 'Lupa PIN' : 'Buat PIN')), body: SafeArea( child: Padding( padding: const EdgeInsets.all(20), child: _step == _Step.done ? _buildDone() : _buildForm(state), ), ), ), ); }, ); } Widget _buildForm(PinSetupFormState state) { final isOtp = _step == _Step.otp; final title = switch (_step) { _Step.otp => 'Masukkan kode verifikasi', _Step.pin => _isReset ? 'Buat PIN baru' : 'Buat PIN', _Step.confirm => 'Konfirmasi PIN', _Step.done => '', }; final description = switch (_step) { _Step.otp => 'Kami mengirim kode 6 digit ke WhatsApp nomor kamu yang terdaftar.', _Step.pin => 'PIN 6 digit dipakai untuk membuat kode bayar, tukar EnakCoin, dan ' 'transfer. PIN berbeda dengan password login.', _Step.confirm => 'Masukkan kembali PIN yang sama.', _Step.done => '', }; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildStepIndicator(), const SizedBox(height: 20), Text(title, style: AppStyle.xl.copyWith(fontWeight: FontWeight.w700)), const SizedBox(height: 8), Text( description, style: AppStyle.md.copyWith(color: AppColor.textSecondary), ), const SizedBox(height: 28), if (isOtp && state.otp == null) Center( child: state.isRequestingOtp ? const SpinKitFadingCircle(color: AppColor.primary, size: 32) : AppOutlineButton( title: 'Kirim kode', onPressed: _canResend(state) ? _requestOtp : null, ), ) else PinCodeField( key: ValueKey(_step), controller: isOtp ? _otpController : _pinController, obscure: !isOtp, enabled: !state.isSubmitting, hasError: _errorText != null, onCompleted: isOtp ? _onOtpCompleted : _onPinCompleted, ), const SizedBox(height: 16), if (state.isSubmitting) const Center( child: SpinKitFadingCircle(color: AppColor.primary, size: 28), ) else if (_errorText != null) Text( _errorText!, style: AppStyle.md.copyWith( color: AppColor.error, fontWeight: FontWeight.w600, ), ), if (isOtp && state.otp != null) ...[ const SizedBox(height: 16), _buildResend(state), ], ], ); } bool _canResend(PinSetupFormState state) => !state.isRequestingOtp && (state.resendAvailableAt == null || DateTime.now().isAfter(state.resendAvailableAt!)); void _requestOtp() { _otpController.clear(); setState(() => _errorText = null); context.read().add( PinSetupFormEvent.otpRequested(widget.purpose), ); } Widget _buildResend(PinSetupFormState state) { final availableAt = state.resendAvailableAt; if (availableAt != null && DateTime.now().isBefore(availableAt)) { return Center( child: Text( 'Kirim ulang kode dalam ' '${formatCountdown(availableAt.difference(DateTime.now()))}', style: AppStyle.sm.copyWith(color: AppColor.textSecondary), ), ); } return Center( child: TextButton( onPressed: _canResend(state) ? _requestOtp : null, child: Text( state.isRequestingOtp ? 'Mengirim...' : 'Kirim ulang kode', style: AppStyle.md.copyWith( color: AppColor.success, fontWeight: FontWeight.w600, ), ), ), ); } Widget _buildStepIndicator() { final index = _Step.values.indexOf(_step); return Row( children: List.generate(3, (i) { return Expanded( child: Container( height: 4, margin: EdgeInsets.only(right: i == 2 ? 0 : 6), decoration: BoxDecoration( color: i <= index ? AppColor.primary : AppColor.border, borderRadius: BorderRadius.circular(2), ), ), ); }), ); } Widget _buildDone() { return Column( children: [ const Spacer(), const Icon(Icons.verified_user, color: AppColor.success, size: 72), const SizedBox(height: 16), Text( _isReset ? 'PIN berhasil direset' : 'PIN berhasil dibuat', style: AppStyle.xl.copyWith(fontWeight: FontWeight.w700), textAlign: TextAlign.center, ), const SizedBox(height: 8), Text( _isReset ? 'Demi keamanan, transfer keluar ditahan selama 24 jam. ' 'Kode bayar dan tukar EnakCoin tetap bisa dipakai.' : 'Gunakan PIN ini untuk membuat kode bayar, tukar EnakCoin, ' 'dan transfer. Jangan beritahu PIN kepada siapa pun.', style: AppStyle.md.copyWith(color: AppColor.textSecondary), textAlign: TextAlign.center, ), const Spacer(), AppElevatedButton( title: 'Selesai', onPressed: () => Navigator.of(context).pop(true), ), ], ); } }