From 2d29a2f38a94d871bbc5631f57d55e4b6cac4668 Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 27 Aug 2025 18:40:03 +0700 Subject: [PATCH] feat: pin page --- lib/presentation/pages/auth/otp/otp_page.dart | 4 +- lib/presentation/pages/auth/pin/pin_page.dart | 328 ++++++++++++++++++ lib/presentation/router/app_router.dart | 1 + lib/presentation/router/app_router.gr.dart | 92 +++-- 4 files changed, 401 insertions(+), 24 deletions(-) create mode 100644 lib/presentation/pages/auth/pin/pin_page.dart diff --git a/lib/presentation/pages/auth/otp/otp_page.dart b/lib/presentation/pages/auth/otp/otp_page.dart index 8a4d07a..31ac7da 100644 --- a/lib/presentation/pages/auth/otp/otp_page.dart +++ b/lib/presentation/pages/auth/otp/otp_page.dart @@ -5,6 +5,7 @@ import 'package:flutter/services.dart'; import '../../../../common/theme/theme.dart'; import '../../../components/button/button.dart'; +import '../../../router/app_router.gr.dart'; @RoutePage() class OtpPage extends StatefulWidget { @@ -82,8 +83,7 @@ class _OtpPageState extends State { void _verifyCode() { String code = _controllers.map((controller) => controller.text).join(); if (code.length == 6) { - // Add your verification logic here - print('Verifying code: $code'); + context.router.push(PinRoute(isCreatePin: true)); } } diff --git a/lib/presentation/pages/auth/pin/pin_page.dart b/lib/presentation/pages/auth/pin/pin_page.dart new file mode 100644 index 0000000..86aca6c --- /dev/null +++ b/lib/presentation/pages/auth/pin/pin_page.dart @@ -0,0 +1,328 @@ +import 'dart:async'; +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../../../common/theme/theme.dart'; +import '../../../components/button/button.dart'; + +@RoutePage() +class PinPage extends StatefulWidget { + final bool isCreatePin; // true for creating PIN, false for entering PIN + final String? title; // Optional custom title + + const PinPage({super.key, this.isCreatePin = true, this.title}); + + @override + State createState() => _PinPageState(); +} + +class _PinPageState extends State { + final List _controllers = List.generate( + 6, + (index) => TextEditingController(), + ); + final List _focusNodes = List.generate(6, (index) => FocusNode()); + + String _firstPin = ''; + bool _isConfirmingPin = false; + bool _isPinMismatch = false; + + @override + void initState() { + super.initState(); + } + + void _onPinChanged(String value, int index) { + if (value.isNotEmpty) { + // Move to next field + if (index < 5) { + _focusNodes[index + 1].requestFocus(); + } else { + // Last field, unfocus and process PIN + _focusNodes[index].unfocus(); + _processPinInput(); + } + } else { + // Handle backspace - move to previous field + if (index > 0) { + _focusNodes[index - 1].requestFocus(); + } + } + } + + void _processPinInput() { + String currentPin = _controllers + .map((controller) => controller.text) + .join(); + + if (currentPin.length == 6) { + if (widget.isCreatePin) { + if (!_isConfirmingPin) { + // First PIN entry - store and ask for confirmation + _firstPin = currentPin; + setState(() { + _isConfirmingPin = true; + _isPinMismatch = false; + }); + _clearPinFields(); + } else { + // Confirming PIN + if (currentPin == _firstPin) { + // PINs match - create PIN + _createPin(currentPin); + } else { + // PINs don't match + setState(() { + _isPinMismatch = true; + }); + _clearPinFields(); + // Auto-hide error after 2 seconds + Timer(const Duration(seconds: 2), () { + if (mounted) { + setState(() { + _isPinMismatch = false; + }); + } + }); + } + } + } else { + // Entering existing PIN + _verifyPin(currentPin); + } + } + } + + void _clearPinFields() { + for (var controller in _controllers) { + controller.clear(); + } + _focusNodes[0].requestFocus(); + } + + void _createPin(String pin) { + // Add your PIN creation logic here + print('Creating PIN: $pin'); + // Navigate to next screen or show success message + } + + void _verifyPin(String pin) { + // Add your PIN verification logic here + print('Verifying PIN: $pin'); + // Navigate to next screen or show error + } + + void _resetPinCreation() { + setState(() { + _isConfirmingPin = false; + _firstPin = ''; + _isPinMismatch = false; + }); + _clearPinFields(); + } + + String get _getTitle { + if (widget.title != null) return widget.title!; + + if (widget.isCreatePin) { + return _isConfirmingPin ? 'Konfirmasi PIN' : 'Buat PIN Baru'; + } else { + return 'Masukan PIN'; + } + } + + String get _getDescription { + if (widget.isCreatePin) { + if (_isConfirmingPin) { + return 'Masukan kembali PIN untuk konfirmasi'; + } else { + return 'Buat PIN 6 digit untuk keamanan akun Anda'; + } + } else { + return 'Masukan PIN 6 digit Anda untuk melanjutkan'; + } + } + + @override + void dispose() { + for (var controller in _controllers) { + controller.dispose(); + } + for (var focusNode in _focusNodes) { + focusNode.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.isCreatePin ? 'Buat PIN' : 'Masukan PIN'), + leading: widget.isCreatePin && _isConfirmingPin + ? IconButton( + icon: Icon(Icons.arrow_back), + onPressed: _resetPinCreation, + ) + : null, + ), + body: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 20), + + // Title + Text( + _getTitle, + style: AppStyle.xl.copyWith( + fontWeight: FontWeight.w600, + color: AppColor.textPrimary, + ), + ), + + const SizedBox(height: 12), + + // Description + Text( + _getDescription, + style: AppStyle.sm.copyWith( + color: AppColor.textSecondary, + height: 1.4, + ), + ), + + // Error message for PIN mismatch + if (_isPinMismatch) ...[ + const SizedBox(height: 8), + Text( + 'PIN tidak sama. Silakan coba lagi.', + style: AppStyle.sm.copyWith( + color: AppColor.error, + fontWeight: FontWeight.w500, + height: 1.4, + ), + ), + ], + + const SizedBox(height: 40), + + // PIN input fields + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: List.generate(6, (index) { + return Expanded( + child: Padding( + padding: EdgeInsets.only(right: index == 5 ? 0 : 8.0), + child: TextFormField( + controller: _controllers[index], + focusNode: _focusNodes[index], + keyboardType: TextInputType.number, + maxLength: 1, + obscureText: true, // Hide PIN input + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: InputDecoration( + counterText: '', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: _isPinMismatch + ? AppColor.error + : AppColor.border, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: _isPinMismatch + ? AppColor.error + : AppColor.primary, + width: 2, + ), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: AppColor.error), + ), + ), + textAlign: TextAlign.center, + style: AppStyle.lg.copyWith( + color: AppColor.primary, + fontWeight: FontWeight.w600, + ), + cursorColor: AppColor.primary, + onChanged: (value) { + setState(() { + _isPinMismatch = false; + }); + _onPinChanged(value, index); + }, + ), + ), + ); + }), + ), + + const SizedBox(height: 40), + + // Progress indicator for PIN creation + if (widget.isCreatePin) ...[ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AppColor.primary, + ), + ), + const SizedBox(width: 8), + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: _isConfirmingPin + ? AppColor.primary + : AppColor.border, + ), + ), + ], + ), + const SizedBox(height: 8), + Center( + child: Text( + _isConfirmingPin ? 'Langkah 2 dari 2' : 'Langkah 1 dari 2', + style: AppStyle.xs.copyWith(color: AppColor.textSecondary), + ), + ), + ], + + const Spacer(), + + // Continue Button + AppElevatedButton( + title: widget.isCreatePin + ? (_isConfirmingPin ? 'Konfirmasi' : 'Lanjutkan') + : 'Masuk', + onPressed: () { + String pin = _controllers + .map((controller) => controller.text) + .join(); + if (pin.length == 6) { + _processPinInput(); + } + }, + ), + + const SizedBox(height: 24), + ], + ), + ), + ); + } +} diff --git a/lib/presentation/router/app_router.dart b/lib/presentation/router/app_router.dart index 8c08e32..041e67f 100644 --- a/lib/presentation/router/app_router.dart +++ b/lib/presentation/router/app_router.dart @@ -15,5 +15,6 @@ class AppRouter extends RootStackRouter { AutoRoute(page: LoginRoute.page), AutoRoute(page: RegisterRoute.page), AutoRoute(page: OtpRoute.page), + AutoRoute(page: PinRoute.page), ]; } diff --git a/lib/presentation/router/app_router.gr.dart b/lib/presentation/router/app_router.gr.dart index 3a98e12..b45b698 100644 --- a/lib/presentation/router/app_router.gr.dart +++ b/lib/presentation/router/app_router.gr.dart @@ -9,24 +9,26 @@ // coverage:ignore-file // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'package:auto_route/auto_route.dart' as _i6; +import 'package:auto_route/auto_route.dart' as _i7; import 'package:enaklo/presentation/pages/auth/login/login_page.dart' as _i1; import 'package:enaklo/presentation/pages/auth/otp/otp_page.dart' as _i3; +import 'package:enaklo/presentation/pages/auth/pin/pin_page.dart' as _i4; import 'package:enaklo/presentation/pages/auth/register/register_page.dart' - as _i4; + as _i5; import 'package:enaklo/presentation/pages/onboarding/onboarding_page.dart' as _i2; -import 'package:enaklo/presentation/pages/splash/splash_page.dart' as _i5; +import 'package:enaklo/presentation/pages/splash/splash_page.dart' as _i6; +import 'package:flutter/material.dart' as _i8; /// generated route for /// [_i1.LoginPage] -class LoginRoute extends _i6.PageRouteInfo { - const LoginRoute({List<_i6.PageRouteInfo>? children}) +class LoginRoute extends _i7.PageRouteInfo { + const LoginRoute({List<_i7.PageRouteInfo>? children}) : super(LoginRoute.name, initialChildren: children); static const String name = 'LoginRoute'; - static _i6.PageInfo page = _i6.PageInfo( + static _i7.PageInfo page = _i7.PageInfo( name, builder: (data) { return const _i1.LoginPage(); @@ -36,13 +38,13 @@ class LoginRoute extends _i6.PageRouteInfo { /// generated route for /// [_i2.OnboardingPage] -class OnboardingRoute extends _i6.PageRouteInfo { - const OnboardingRoute({List<_i6.PageRouteInfo>? children}) +class OnboardingRoute extends _i7.PageRouteInfo { + const OnboardingRoute({List<_i7.PageRouteInfo>? children}) : super(OnboardingRoute.name, initialChildren: children); static const String name = 'OnboardingRoute'; - static _i6.PageInfo page = _i6.PageInfo( + static _i7.PageInfo page = _i7.PageInfo( name, builder: (data) { return const _i2.OnboardingPage(); @@ -52,13 +54,13 @@ class OnboardingRoute extends _i6.PageRouteInfo { /// generated route for /// [_i3.OtpPage] -class OtpRoute extends _i6.PageRouteInfo { - const OtpRoute({List<_i6.PageRouteInfo>? children}) +class OtpRoute extends _i7.PageRouteInfo { + const OtpRoute({List<_i7.PageRouteInfo>? children}) : super(OtpRoute.name, initialChildren: children); static const String name = 'OtpRoute'; - static _i6.PageInfo page = _i6.PageInfo( + static _i7.PageInfo page = _i7.PageInfo( name, builder: (data) { return const _i3.OtpPage(); @@ -67,33 +69,79 @@ class OtpRoute extends _i6.PageRouteInfo { } /// generated route for -/// [_i4.RegisterPage] -class RegisterRoute extends _i6.PageRouteInfo { - const RegisterRoute({List<_i6.PageRouteInfo>? children}) +/// [_i4.PinPage] +class PinRoute extends _i7.PageRouteInfo { + PinRoute({ + _i8.Key? key, + bool isCreatePin = true, + String? title, + List<_i7.PageRouteInfo>? children, + }) : super( + PinRoute.name, + args: PinRouteArgs(key: key, isCreatePin: isCreatePin, title: title), + initialChildren: children, + ); + + static const String name = 'PinRoute'; + + static _i7.PageInfo page = _i7.PageInfo( + name, + builder: (data) { + final args = data.argsAs( + orElse: () => const PinRouteArgs(), + ); + return _i4.PinPage( + key: args.key, + isCreatePin: args.isCreatePin, + title: args.title, + ); + }, + ); +} + +class PinRouteArgs { + const PinRouteArgs({this.key, this.isCreatePin = true, this.title}); + + final _i8.Key? key; + + final bool isCreatePin; + + final String? title; + + @override + String toString() { + return 'PinRouteArgs{key: $key, isCreatePin: $isCreatePin, title: $title}'; + } +} + +/// generated route for +/// [_i5.RegisterPage] +class RegisterRoute extends _i7.PageRouteInfo { + const RegisterRoute({List<_i7.PageRouteInfo>? children}) : super(RegisterRoute.name, initialChildren: children); static const String name = 'RegisterRoute'; - static _i6.PageInfo page = _i6.PageInfo( + static _i7.PageInfo page = _i7.PageInfo( name, builder: (data) { - return const _i4.RegisterPage(); + return const _i5.RegisterPage(); }, ); } /// generated route for -/// [_i5.SplashPage] -class SplashRoute extends _i6.PageRouteInfo { - const SplashRoute({List<_i6.PageRouteInfo>? children}) +/// [_i6.SplashPage] +class SplashRoute extends _i7.PageRouteInfo { + const SplashRoute({List<_i7.PageRouteInfo>? children}) : super(SplashRoute.name, initialChildren: children); static const String name = 'SplashRoute'; - static _i6.PageInfo page = _i6.PageInfo( + static _i7.PageInfo page = _i7.PageInfo( name, builder: (data) { - return const _i5.SplashPage(); + return const _i6.SplashPage(); }, ); }