From c956cb84bf7bb5ad380376fa2d787886c3cf7df7 Mon Sep 17 00:00:00 2001 From: TuanLamNguyen Date: Tue, 10 Feb 2026 16:29:28 +0100 Subject: [PATCH 1/5] feat: create Pin flow with setup, confirm and verify. --- lib/di.dart | 4 + lib/main.dart | 95 ++++++---- .../repository/settings_repository.dart | 4 + lib/packages/storage/secure_storage.dart | 19 ++ lib/router.dart | 10 ++ lib/screens/pin/bloc/auth/pin_auth_cubit.dart | 56 ++++++ lib/screens/pin/bloc/auth/pin_auth_state.dart | 24 +++ lib/screens/pin/bloc/pin_cubit.dart | 41 ----- .../pin/bloc/setup_pin/setup_pin_cubit.dart | 77 ++++++++ .../pin/bloc/setup_pin/setup_pin_state.dart | 32 ++++ .../pin/bloc/verify_pin/verify_pin_cubit.dart | 35 ++++ .../pin/bloc/verify_pin/verify_pin_state.dart | 24 +++ lib/screens/pin/pin_page.dart | 16 -- lib/screens/pin/pin_view.dart | 43 ----- lib/screens/pin/setup_pin_page.dart | 106 +++++++++++ lib/screens/pin/verify_pin_page.dart | 116 ++++++++++++ .../pin/widgets/forgot_pin_bottom_sheet.dart | 101 +++++++++++ lib/screens/settings/settings_page.dart | 170 +++++++++--------- 18 files changed, 756 insertions(+), 217 deletions(-) create mode 100644 lib/screens/pin/bloc/auth/pin_auth_cubit.dart create mode 100644 lib/screens/pin/bloc/auth/pin_auth_state.dart delete mode 100644 lib/screens/pin/bloc/pin_cubit.dart create mode 100644 lib/screens/pin/bloc/setup_pin/setup_pin_cubit.dart create mode 100644 lib/screens/pin/bloc/setup_pin/setup_pin_state.dart create mode 100644 lib/screens/pin/bloc/verify_pin/verify_pin_cubit.dart create mode 100644 lib/screens/pin/bloc/verify_pin/verify_pin_state.dart delete mode 100644 lib/screens/pin/pin_page.dart delete mode 100644 lib/screens/pin/pin_view.dart create mode 100644 lib/screens/pin/setup_pin_page.dart create mode 100644 lib/screens/pin/verify_pin_page.dart create mode 100644 lib/screens/pin/widgets/forgot_pin_bottom_sheet.dart diff --git a/lib/di.dart b/lib/di.dart index b9c1a2fd..0b1d7c71 100644 --- a/lib/di.dart +++ b/lib/di.dart @@ -29,6 +29,7 @@ import 'package:realunit_wallet/packages/storage/database.dart'; import 'package:realunit_wallet/packages/storage/secure_storage.dart'; import 'package:realunit_wallet/router.dart'; import 'package:realunit_wallet/screens/home/bloc/home_bloc.dart'; +import 'package:realunit_wallet/screens/pin/bloc/auth/pin_auth_cubit.dart'; import 'package:realunit_wallet/screens/settings/bloc/settings_bloc.dart'; import 'package:realunit_wallet/setup.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -152,6 +153,9 @@ void setupBlocs() { getIt(), ), ); + getIt.registerSingleton( + PinAuthCubit(getIt(), getIt())..initialize(), + ); } Future _existsDatabaseFile() async => File(await AppDatabase.getDatabasePath()).exists(); diff --git a/lib/main.dart b/lib/main.dart index 8d94a2c5..ea5aac5f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -9,8 +9,12 @@ import 'package:realunit_wallet/generated/i18n.dart'; import 'package:realunit_wallet/packages/service/app_store.dart'; import 'package:realunit_wallet/packages/service/balance_service.dart'; import 'package:realunit_wallet/packages/utils/fuck_firebase.dart'; +import 'package:realunit_wallet/screens/dashboard/dashboard_page.dart'; import 'package:realunit_wallet/screens/home/bloc/home_bloc.dart'; import 'package:realunit_wallet/screens/onboarding/onboarding_completed_page.dart'; +import 'package:realunit_wallet/screens/pin/bloc/auth/pin_auth_cubit.dart'; +import 'package:realunit_wallet/screens/pin/setup_pin_page.dart'; +import 'package:realunit_wallet/screens/pin/verify_pin_page.dart'; import 'package:realunit_wallet/screens/settings/bloc/settings_bloc.dart'; import 'package:realunit_wallet/styles/themes.dart'; @@ -81,8 +85,8 @@ class _WalletAppState extends State { void _onDetached() => developer.log('detached'); void _onResumed() { - getIt() - .updateBalances(getIt().primaryAddress); + getIt().onAppResumed(); + getIt().updateBalances(getIt().primaryAddress); } void _onInactive() => developer.log('inactive', name: 'AppLifecycleListener'); @@ -90,45 +94,70 @@ class _WalletAppState extends State { void _onHidden() => developer.log('hidden', name: 'AppLifecycleListener'); void _onPaused() { + getIt().onAppPaused(); getIt().cancelSync(); } @override Widget build(BuildContext context) => MultiBlocProvider( - providers: [ - BlocProvider.value(value: getIt()), - BlocProvider.value(value: getIt()), + providers: [ + BlocProvider.value(value: getIt()), + BlocProvider.value(value: getIt()), + BlocProvider.value(value: getIt()), + ], + child: BlocBuilder( + builder: (context, settingsState) => MaterialApp.router( + debugShowCheckedModeBanner: false, + theme: realUnitTheme, + supportedLocales: S.delegate.supportedLocales, + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, ], - child: BlocBuilder( - builder: (context, state) => MaterialApp.router( - debugShowCheckedModeBanner: false, - theme: realUnitTheme, - supportedLocales: S.delegate.supportedLocales, - localizationsDelegates: const [ - S.delegate, - GlobalMaterialLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - ], - locale: Locale(state.language.code), - routerConfig: getIt(), - builder: (context, child) => BlocListener( - listener: (context, state) { - if (!state.isLoadingWallet) { - var targetRoute = '/welcome'; - if (state.openWallet != null) { - targetRoute = OnboardingCompletedPage.route; - if (state.onboardingCompleted) { - targetRoute = '/dashboard'; - } - } - - getIt().go(targetRoute); + locale: Locale(settingsState.language.code), + routerConfig: getIt(), + builder: (context, child) => MultiBlocListener( + listeners: [ + BlocListener( + listener: (context, homeState) { + if (!homeState.isLoadingWallet) { + _navigate(context); } }, - child: child, ), - ), + BlocListener( + listener: (context, pinState) { + _navigate(context); + }, + ), + ], + child: child ?? const SizedBox.shrink(), ), - ); + ), + ), + ); + + void _navigate(BuildContext context) { + final homeState = context.read().state; + final pinState = context.read().state; + + if (homeState.isLoadingWallet) return; + + String targetRoute; + if (homeState.openWallet == null) { + targetRoute = '/welcome'; + } else if (!homeState.onboardingCompleted) { + targetRoute = OnboardingCompletedPage.route; + } else if (!pinState.isPinSetup) { + targetRoute = SetupPinPage.route; + } else if (!pinState.isPinVerified) { + targetRoute = VerifyPinPage.route; + } else { + targetRoute = DashboardPage.routeName; + } + + getIt().go(targetRoute); + } } diff --git a/lib/packages/repository/settings_repository.dart b/lib/packages/repository/settings_repository.dart index 1aa81513..f37f0dcc 100644 --- a/lib/packages/repository/settings_repository.dart +++ b/lib/packages/repository/settings_repository.dart @@ -32,4 +32,8 @@ class SettingsRepository { } set networkMode(NetworkMode mode) => _sharedPreferences.setString('networkMode', mode.name); + + bool get isPinEnabled => _sharedPreferences.getBool('isPinEnabled') ?? false; + + set isPinEnabled(bool enabled) => _sharedPreferences.setBool('isPinEnabled', enabled); } diff --git a/lib/packages/storage/secure_storage.dart b/lib/packages/storage/secure_storage.dart index 243983cf..d5812c15 100644 --- a/lib/packages/storage/secure_storage.dart +++ b/lib/packages/storage/secure_storage.dart @@ -9,11 +9,14 @@ import 'package:web3dart/crypto.dart'; class SecureStorage { static const _encryptionKey = 'drift.encryption.password'; + static const _pinHashKey = 'pin.hash'; final FlutterSecureStorage _secureStorage; const SecureStorage() : _secureStorage = const FlutterSecureStorage(); + // Database + static String getNewEncryptionKey({int keySize = 32, int iterations = 10000}) { final key = const Uuid().v4(); final salt = Uint8List(9)..setRange(0, 9, utf8.encode('dEURO key')); @@ -28,4 +31,20 @@ class SecureStorage { Future setEncryptionKey(String key) => _secureStorage.write(key: _encryptionKey, value: key); + + // Pin + + static String hashPin(String pin) { + final salt = Uint8List(8)..setRange(0, 8, utf8.encode('PIN salt')); + final derivator = KeyDerivator('SHA-256/HMAC/PBKDF2'); + final params = Pbkdf2Parameters(salt, 10000, 32); + derivator.init(params); + return bytesToHex(derivator.process(utf8.encode(pin))); + } + + Future getPinHash() => _secureStorage.read(key: _pinHashKey); + + Future setPinHash(String hash) => _secureStorage.write(key: _pinHashKey, value: hash); + + Future deletePinHash() => _secureStorage.delete(key: _pinHashKey); } diff --git a/lib/router.dart b/lib/router.dart index 30bc4c25..0e8dd470 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -9,6 +9,8 @@ import 'package:realunit_wallet/screens/dashboard/dashboard_page.dart'; import 'package:realunit_wallet/screens/home/home.dart'; import 'package:realunit_wallet/screens/kyc/kyc_page_manager.dart'; import 'package:realunit_wallet/screens/onboarding/onboarding_completed_page.dart'; +import 'package:realunit_wallet/screens/pin/setup_pin_page.dart'; +import 'package:realunit_wallet/screens/pin/verify_pin_page.dart'; import 'package:realunit_wallet/screens/receive/receive_page.dart'; import 'package:realunit_wallet/screens/restore_wallet/restore_wallet_page.dart'; import 'package:realunit_wallet/screens/sell/sell_page.dart'; @@ -58,6 +60,14 @@ void setupRouter() { path: OnboardingCompletedPage.route, builder: (context, state) => const OnboardingCompletedPage(), ), + GoRoute( + path: SetupPinPage.route, + builder: (context, state) => const SetupPinPage(), + ), + GoRoute( + path: VerifyPinPage.route, + builder: (context, state) => const VerifyPinPage(), + ), GoRoute( path: DashboardPage.routeName, builder: (context, state) => const DashboardPage(), diff --git a/lib/screens/pin/bloc/auth/pin_auth_cubit.dart b/lib/screens/pin/bloc/auth/pin_auth_cubit.dart new file mode 100644 index 00000000..c6f5f7cb --- /dev/null +++ b/lib/screens/pin/bloc/auth/pin_auth_cubit.dart @@ -0,0 +1,56 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:realunit_wallet/packages/repository/settings_repository.dart'; +import 'package:realunit_wallet/packages/storage/secure_storage.dart'; + +part 'pin_auth_state.dart'; + +class PinAuthCubit extends Cubit { + static const _lockoutDuration = Duration(minutes: 1); + + PinAuthCubit( + this._secureStorage, + this._settingsRepository, + ) : super(const PinAuthState()); + + final SecureStorage _secureStorage; + final SettingsRepository _settingsRepository; + + DateTime? _lastBackgroundTime; + + void initialize() { + final isPinSetup = _settingsRepository.isPinEnabled; + emit( + state.copyWith( + isPinSetup: isPinSetup, + isPinVerified: !isPinSetup, + ), + ); + } + + void onPinSetupComplete() => emit( + state.copyWith(isPinSetup: true, isPinVerified: true), + ); + + void onPinVerified() => emit(state.copyWith(isPinVerified: true)); + + void onAppPaused() => _lastBackgroundTime = DateTime.now(); + + void onAppResumed() { + if (!state.isPinSetup) return; + + final lastBackground = _lastBackgroundTime; + if (lastBackground == null) return; + + final elapsed = DateTime.now().difference(lastBackground); + if (elapsed >= _lockoutDuration) { + emit(state.copyWith(isPinVerified: false)); + } + } + + void reset() { + _settingsRepository.isPinEnabled = false; + _secureStorage.deletePinHash(); + emit(const PinAuthState()); + } +} diff --git a/lib/screens/pin/bloc/auth/pin_auth_state.dart b/lib/screens/pin/bloc/auth/pin_auth_state.dart new file mode 100644 index 00000000..9f826294 --- /dev/null +++ b/lib/screens/pin/bloc/auth/pin_auth_state.dart @@ -0,0 +1,24 @@ +part of 'pin_auth_cubit.dart'; + +class PinAuthState extends Equatable { + final bool isPinSetup; + final bool isPinVerified; + + const PinAuthState({ + this.isPinSetup = false, + this.isPinVerified = false, + }); + + PinAuthState copyWith({ + bool? isPinSetup, + bool? isPinVerified, + }) { + return PinAuthState( + isPinSetup: isPinSetup ?? this.isPinSetup, + isPinVerified: isPinVerified ?? this.isPinVerified, + ); + } + + @override + List get props => [isPinSetup, isPinVerified]; +} diff --git a/lib/screens/pin/bloc/pin_cubit.dart b/lib/screens/pin/bloc/pin_cubit.dart deleted file mode 100644 index 3baa778d..00000000 --- a/lib/screens/pin/bloc/pin_cubit.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:realunit_wallet/packages/storage/database.dart'; - -class PinState { - final String pin; - final bool wrongTry; - - PinState({required this.pin, required this.wrongTry}); - - PinState copyWith({ - String? pin, - bool? wrongTry, - }) => - PinState( - pin: pin ?? this.pin, - wrongTry: wrongTry ?? this.wrongTry, - ); -} - -class PinCubit extends Cubit { - PinCubit(this.maxPinLength, this.onSuccess) : super(PinState(pin: '', wrongTry: false)); - - final int maxPinLength; - final Function(String database) onSuccess; - - void amountAdd(int index) { - if (state.pin.length == maxPinLength) return; - emit(state.copyWith(pin: '${state.pin}$index', wrongTry: false)); - if (state.pin.length == maxPinLength) checkPin(); - } - - void amountDelete() => emit(state.copyWith(pin: state.pin.substring(0, state.pin.length - 1))); - - Future checkPin() async { - final pin = state.pin; - final isCorrectPin = await tryOpeningDatabase(pin); - emit(state.copyWith(pin: '', wrongTry: !isCorrectPin)); - - if (isCorrectPin) onSuccess.call(pin); - } -} diff --git a/lib/screens/pin/bloc/setup_pin/setup_pin_cubit.dart b/lib/screens/pin/bloc/setup_pin/setup_pin_cubit.dart new file mode 100644 index 00000000..e4aee141 --- /dev/null +++ b/lib/screens/pin/bloc/setup_pin/setup_pin_cubit.dart @@ -0,0 +1,77 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:realunit_wallet/packages/repository/settings_repository.dart'; +import 'package:realunit_wallet/packages/storage/secure_storage.dart'; + +part 'setup_pin_state.dart'; + +class SetupPinCubit extends Cubit { + SetupPinCubit( + this._secureStorage, + this._settingsRepository, + ) : super(const SetupPinState()); + + final SecureStorage _secureStorage; + final SettingsRepository _settingsRepository; + final int maxPinLength = 4; + + String? _createPin; + + void addDigit(int digit) { + if (state.currentPin.length >= maxPinLength) return; + + final newPin = '${state.currentPin}$digit'; + emit(state.copyWith(currentPin: newPin, mismatch: false)); + + if (newPin.length == maxPinLength) { + _onPinComplete(newPin); + } + } + + void deleteDigit() { + if (state.currentPin.isEmpty) return; + emit( + state.copyWith( + currentPin: state.currentPin.substring(0, state.currentPin.length - 1), + mismatch: false, + ), + ); + } + + void _onPinComplete(String pin) { + switch (state.mode) { + case SetupPinMode.create: + _createPin = pin; + emit( + state.copyWith( + mode: SetupPinMode.confirm, + currentPin: '', + ), + ); + break; + case SetupPinMode.confirm: + _confirmPin(pin); + break; + } + } + + Future _confirmPin(String confirmPin) async { + if (confirmPin == _createPin) { + final hash = SecureStorage.hashPin(confirmPin); + await _secureStorage.setPinHash(hash); + _settingsRepository.isPinEnabled = true; + emit(state.copyWith(isComplete: true)); + } else { + emit( + state.copyWith( + currentPin: '', + mismatch: true, + ), + ); + } + } + + void reset() { + emit(const SetupPinState()); + } +} diff --git a/lib/screens/pin/bloc/setup_pin/setup_pin_state.dart b/lib/screens/pin/bloc/setup_pin/setup_pin_state.dart new file mode 100644 index 00000000..76c39c93 --- /dev/null +++ b/lib/screens/pin/bloc/setup_pin/setup_pin_state.dart @@ -0,0 +1,32 @@ +part of 'setup_pin_cubit.dart'; + +enum SetupPinMode { create, confirm } + +class SetupPinState extends Equatable { + final SetupPinMode mode; + final String currentPin; + final bool mismatch; + final bool isComplete; + + const SetupPinState({ + this.mode = SetupPinMode.create, + this.currentPin = '', + this.mismatch = false, + this.isComplete = false, + }); + + SetupPinState copyWith({ + SetupPinMode? mode, + String? currentPin, + bool? mismatch, + bool? isComplete, + }) => SetupPinState( + mode: mode ?? this.mode, + currentPin: currentPin ?? this.currentPin, + mismatch: mismatch ?? this.mismatch, + isComplete: isComplete ?? this.isComplete, + ); + + @override + List get props => [mode, currentPin, mismatch, isComplete]; +} diff --git a/lib/screens/pin/bloc/verify_pin/verify_pin_cubit.dart b/lib/screens/pin/bloc/verify_pin/verify_pin_cubit.dart new file mode 100644 index 00000000..3f8b6a10 --- /dev/null +++ b/lib/screens/pin/bloc/verify_pin/verify_pin_cubit.dart @@ -0,0 +1,35 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:realunit_wallet/packages/storage/secure_storage.dart'; + +part 'verify_pin_state.dart'; + +class VerifyPinCubit extends Cubit { + VerifyPinCubit(this._secureStorage) : super(const VerifyPinState(pin: '')); + + final SecureStorage _secureStorage; + final int maxPinLength = 4; + + void addDigit(int digit) { + if (state.pin.length == maxPinLength) return; + emit(state.copyWith(pin: '${state.pin}$digit')); + if (state.pin.length == maxPinLength) checkPin(); + } + + void deleteDigit() { + if (state.pin.isEmpty) return; + emit(state.copyWith(pin: state.pin.substring(0, state.pin.length - 1))); + } + + Future checkPin() async { + final storedHash = await _secureStorage.getPinHash(); + final enteredHash = SecureStorage.hashPin(state.pin); + final isCorrect = storedHash == enteredHash; + + if (isCorrect) { + emit(const VerifyPinSuccess()); + } else { + emit(const VerifyPinFailure()); + } + } +} diff --git a/lib/screens/pin/bloc/verify_pin/verify_pin_state.dart b/lib/screens/pin/bloc/verify_pin/verify_pin_state.dart new file mode 100644 index 00000000..18c14060 --- /dev/null +++ b/lib/screens/pin/bloc/verify_pin/verify_pin_state.dart @@ -0,0 +1,24 @@ +part of 'verify_pin_cubit.dart'; + +class VerifyPinState extends Equatable { + final String pin; + + const VerifyPinState({required this.pin}); + + VerifyPinState copyWith({ + String? pin, + }) => VerifyPinState( + pin: pin ?? this.pin, + ); + + @override + List get props => [pin]; +} + +class VerifyPinSuccess extends VerifyPinState { + const VerifyPinSuccess() : super(pin: ''); +} + +class VerifyPinFailure extends VerifyPinState { + const VerifyPinFailure() : super(pin: ''); +} diff --git a/lib/screens/pin/pin_page.dart b/lib/screens/pin/pin_page.dart deleted file mode 100644 index 1347dde0..00000000 --- a/lib/screens/pin/pin_page.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:realunit_wallet/screens/pin/bloc/pin_cubit.dart'; -import 'package:realunit_wallet/screens/pin/pin_view.dart'; - -class PinPage extends StatelessWidget { - const PinPage({super.key, required this.onSuccess}); - - final Function(String database) onSuccess; - - @override - Widget build(BuildContext context) => BlocProvider( - create: (_) => PinCubit(4, onSuccess), - child: const PinView(), - ); -} diff --git a/lib/screens/pin/pin_view.dart b/lib/screens/pin/pin_view.dart deleted file mode 100644 index 4145f465..00000000 --- a/lib/screens/pin/pin_view.dart +++ /dev/null @@ -1,43 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:realunit_wallet/screens/pin/bloc/pin_cubit.dart'; -import 'package:realunit_wallet/screens/pin/widgets/pin_indicator.dart'; -import 'package:realunit_wallet/widgets/number_pad.dart'; - -class PinView extends StatelessWidget { - const PinView({super.key}); - - static const _kPadding = EdgeInsets.only(left: 26, right: 26, bottom: 10); - - @override - Widget build(BuildContext context) => Scaffold( - body: SafeArea( - child: BlocListener( - listener: (_, _) {}, - child: BlocBuilder( - builder: (context, state) => Column( - children: [ - Expanded( - child: Center( - child: PinIndicator( - pinLength: state.pin.length, - expectedPinLength: context.read().maxPinLength, - wrongPin: state.wrongTry, - ), - ), - ), - NumberPad( - onNumberPressed: (index) => context.read().amountAdd(index), - onDeletePressed: () => context.read().amountDelete(), - ), - Padding( - padding: _kPadding, - child: TextButton(onPressed: () {}, child: const Text('Reset Wallet')), - ), - ], - ), - ), - ), - ), - ); -} diff --git a/lib/screens/pin/setup_pin_page.dart b/lib/screens/pin/setup_pin_page.dart new file mode 100644 index 00000000..4c512f60 --- /dev/null +++ b/lib/screens/pin/setup_pin_page.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:realunit_wallet/di.dart'; +import 'package:realunit_wallet/packages/repository/settings_repository.dart'; +import 'package:realunit_wallet/packages/storage/secure_storage.dart'; +import 'package:realunit_wallet/screens/pin/bloc/auth/pin_auth_cubit.dart'; +import 'package:realunit_wallet/screens/pin/bloc/setup_pin/setup_pin_cubit.dart'; +import 'package:realunit_wallet/screens/pin/widgets/pin_indicator.dart'; +import 'package:realunit_wallet/styles/colors.dart'; +import 'package:realunit_wallet/widgets/number_pad.dart'; + +class SetupPinPage extends StatelessWidget { + const SetupPinPage({super.key}); + + static const route = '/pin/setup'; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => SetupPinCubit( + getIt(), + getIt(), + ), + child: const SetupPinView(), + ); + } +} + +class SetupPinView extends StatelessWidget { + const SetupPinView({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: RealUnitColors.brand700, + body: SafeArea( + child: BlocConsumer( + listener: (context, state) { + if (state.isComplete) { + getIt().onPinSetupComplete(); + } + }, + builder: (context, state) { + return Column( + children: [ + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + switch (state.mode) { + SetupPinMode.create => 'Create your PIN', + SetupPinMode.confirm => 'Confirm your PIN', + }, + style: const TextStyle( + fontSize: 26, + fontWeight: FontWeight.w700, + color: RealUnitColors.realUnitBlack, + letterSpacing: -0.52, + height: 30 / 26, + ), + ), + const SizedBox(height: 8), + Text( + switch (state.mode) { + SetupPinMode.create => 'Enter a 4-digit PIN to secure your wallet', + SetupPinMode.confirm => 'Re-enter your PIN to confirm', + }, + style: const TextStyle( + fontSize: 14, + color: RealUnitColors.neutral500, + height: 18 / 14, + ), + ), + const SizedBox(height: 40), + PinIndicator( + pinLength: state.currentPin.length, + expectedPinLength: context.read().maxPinLength, + wrongPin: state.mismatch, + ), + if (state.mismatch) ...[ + const SizedBox(height: 16), + Text( + 'PINs do not match. Try again.', + style: TextStyle( + fontSize: 14, + color: RealUnitColors.status.red600, + ), + ), + ], + ], + ), + ), + NumberPad( + onNumberPressed: (digit) => context.read().addDigit(digit), + onDeletePressed: () => context.read().deleteDigit(), + ), + const SizedBox(height: 40), + ], + ); + }, + ), + ), + ); + } +} diff --git a/lib/screens/pin/verify_pin_page.dart b/lib/screens/pin/verify_pin_page.dart new file mode 100644 index 00000000..79aed47c --- /dev/null +++ b/lib/screens/pin/verify_pin_page.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:realunit_wallet/di.dart'; +import 'package:realunit_wallet/packages/storage/secure_storage.dart'; +import 'package:realunit_wallet/screens/home/bloc/home_bloc.dart'; +import 'package:realunit_wallet/screens/pin/bloc/auth/pin_auth_cubit.dart'; +import 'package:realunit_wallet/screens/pin/bloc/verify_pin/verify_pin_cubit.dart'; +import 'package:realunit_wallet/screens/pin/widgets/forgot_pin_bottom_sheet.dart'; +import 'package:realunit_wallet/screens/pin/widgets/pin_indicator.dart'; +import 'package:realunit_wallet/styles/colors.dart'; +import 'package:realunit_wallet/widgets/number_pad.dart'; + +class VerifyPinPage extends StatelessWidget { + const VerifyPinPage({super.key}); + + static const route = '/pin/verify'; + + @override + Widget build(BuildContext context) => BlocProvider( + create: (_) => VerifyPinCubit( + getIt(), + ), + child: const VerifyPinView(), + ); +} + +class VerifyPinView extends StatelessWidget { + const VerifyPinView({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: RealUnitColors.brand700, + body: SafeArea( + child: BlocConsumer( + listener: (context, state) { + if (state is VerifyPinSuccess) { + getIt().onPinVerified(); + } + }, + builder: (context, state) { + return Column( + children: [ + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + 'Enter your PIN', + style: TextStyle( + fontSize: 26, + fontWeight: FontWeight.w700, + color: RealUnitColors.realUnitBlack, + letterSpacing: -0.52, + height: 30 / 26, + ), + ), + const SizedBox(height: 8), + const Text( + 'Enter your PIN to unlock the wallet', + style: TextStyle( + fontSize: 14, + color: RealUnitColors.neutral500, + height: 18 / 14, + ), + ), + const SizedBox(height: 40), + PinIndicator( + pinLength: state.pin.length, + expectedPinLength: context.read().maxPinLength, + wrongPin: state is VerifyPinFailure, + ), + if (state is VerifyPinFailure) ...[ + const SizedBox(height: 16), + Text( + 'Wrong PIN. Try again.', + style: TextStyle( + fontSize: 14, + color: RealUnitColors.status.red600, + ), + ), + ], + ], + ), + ), + NumberPad( + onNumberPressed: (digit) => context.read().addDigit(digit), + onDeletePressed: () => context.read().deleteDigit(), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 26, vertical: 10), + child: TextButton( + onPressed: () async { + bool? isReset = await showModalBottomSheet( + context: context, + builder: (_) => const ForgotPinBottomSheet(), + ); + if (isReset ?? false) { + await Future.delayed(const Duration(milliseconds: 300)); + if (context.mounted) { + context.read().reset(); + context.read().add(const DeleteCurrentWalletEvent()); + } + } + }, + child: const Text('Forgot PIN? Reset Wallet'), + ), + ), + ], + ); + }, + ), + ), + ); + } +} diff --git a/lib/screens/pin/widgets/forgot_pin_bottom_sheet.dart b/lib/screens/pin/widgets/forgot_pin_bottom_sheet.dart new file mode 100644 index 00000000..bdc047a0 --- /dev/null +++ b/lib/screens/pin/widgets/forgot_pin_bottom_sheet.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/styles/colors.dart'; +import 'package:realunit_wallet/widgets/handlebars.dart'; + +class ForgotPinBottomSheet extends StatelessWidget { + const ForgotPinBottomSheet({super.key}); + + @override + Widget build(BuildContext context) { + return SafeArea( + child: SizedBox( + width: double.infinity, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Handlebars.horizontal( + context, + margin: const EdgeInsets.only(top: 5), + width: 36, + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 40.0, horizontal: 30.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Reset Wallet', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 26, + fontWeight: FontWeight.w700, + height: 30 / 26, + letterSpacing: 26 * -0.02, + ), + ), + const SizedBox(height: 8), + const Text( + 'This will delete your wallet and all associated data. ' + 'Make sure you have your recovery phrase backed up.', + textAlign: TextAlign.center, + style: TextStyle( + color: RealUnitColors.neutral500, + fontSize: 14, + height: 18 / 14, + letterSpacing: 0.0, + ), + ), + const SizedBox(height: 28), + Row( + spacing: 12.0, + children: [ + Expanded( + child: FilledButton( + style: ButtonStyle( + backgroundColor: WidgetStateProperty.all( + RealUnitColors.neutral100, + ), + foregroundColor: WidgetStateProperty.all( + RealUnitColors.realUnitBlack, + ), + ), + onPressed: () => context.pop(), + child: Text( + S.of(context).close, + style: const TextStyle( + color: RealUnitColors.realUnitBlack, + fontSize: 16, + height: 20 / 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + Expanded( + child: FilledButton( + onPressed: () { + Navigator.of(context).pop(true); + }, + child: const Text( + 'Reset', + style: TextStyle( + fontSize: 16, + height: 20 / 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/settings/settings_page.dart b/lib/screens/settings/settings_page.dart index c94aac63..e91c6f7f 100644 --- a/lib/screens/settings/settings_page.dart +++ b/lib/screens/settings/settings_page.dart @@ -5,6 +5,7 @@ import 'package:realunit_wallet/di.dart'; import 'package:realunit_wallet/generated/i18n.dart'; import 'package:realunit_wallet/packages/wallet/wallet.dart'; import 'package:realunit_wallet/screens/home/bloc/home_bloc.dart'; +import 'package:realunit_wallet/screens/pin/bloc/auth/pin_auth_cubit.dart'; import 'package:realunit_wallet/screens/settings/bloc/settings_bloc.dart'; import 'package:realunit_wallet/screens/settings/widgets/settings_confirm_logout_wallet_sheet.dart'; import 'package:realunit_wallet/screens/settings/widgets/settings_section.dart'; @@ -22,94 +23,95 @@ class SettingsPage extends StatelessWidget { @override Widget build(BuildContext context) => Scaffold( - appBar: AppBar( - title: Text( - S.of(context).settings, - ), - ), - body: SingleChildScrollView( - child: Column( - children: [ - BlocBuilder( - bloc: getIt(), - builder: (context, state) => SettingsSections( - settings: [ - SettingOption( - title: S.of(context).settingsLanguages, - leading: const LanguagesIcon(size: 24), - trailing: _forwardIcon, - selectedOption: state.language.name, - onTap: () => context.push('/settings/languages'), - ), - SettingOption( - title: S.of(context).settingsCurrency, - leading: const CurrencyIcon(size: 24), - trailing: _forwardIcon, - selectedOption: state.currency.code, - onTap: () => context.push('/settings/currencies'), - ), - SettingOption( - title: S.of(context).settingsNetwork, - leading: const NodesIcon(size: 24), - trailing: _forwardIcon, - selectedOption: state.networkMode.localizedName(context), - onTap: () => context.push('/settings/network'), - ), - SettingOption( - title: S.of(context).settingsTaxReport, - leading: const DocumentReportIcon(size: 24), - trailing: _forwardIcon, - onTap: () => context.push('/settings/taxReport')), - SettingOption( - title: S.of(context).kycStatus, - leading: const IdentificationIcon(size: 24), - trailing: _forwardIcon, - onTap: null, - ), - SettingOption( - title: S.of(context).userData, - leading: const UserCircleIcon(size: 24), - trailing: _forwardIcon, - onTap: null, - ), - if (context.read().state.openWallet?.walletType == - WalletType.software) - SettingOption( - title: S.of(context).settingsWalletBackup, - leading: const KeySolidIcon(size: 24), - trailing: _forwardIcon, - onTap: () => context.push('/settings/seed'), - ), - ], + appBar: AppBar( + title: Text( + S.of(context).settings, + ), + ), + body: SingleChildScrollView( + child: Column( + children: [ + BlocBuilder( + bloc: getIt(), + builder: (context, state) => SettingsSections( + settings: [ + SettingOption( + title: S.of(context).settingsLanguages, + leading: const LanguagesIcon(size: 24), + trailing: _forwardIcon, + selectedOption: state.language.name, + onTap: () => context.push('/settings/languages'), ), - ), - const Padding( - padding: EdgeInsets.symmetric(horizontal: 20), - child: Divider(color: RealUnitColors.neutral200), - ), - SettingsSections( - settings: [ + SettingOption( + title: S.of(context).settingsCurrency, + leading: const CurrencyIcon(size: 24), + trailing: _forwardIcon, + selectedOption: state.currency.code, + onTap: () => context.push('/settings/currencies'), + ), + SettingOption( + title: S.of(context).settingsNetwork, + leading: const NodesIcon(size: 24), + trailing: _forwardIcon, + selectedOption: state.networkMode.localizedName(context), + onTap: () => context.push('/settings/network'), + ), + SettingOption( + title: S.of(context).settingsTaxReport, + leading: const DocumentReportIcon(size: 24), + trailing: _forwardIcon, + onTap: () => context.push('/settings/taxReport'), + ), + SettingOption( + title: S.of(context).kycStatus, + leading: const IdentificationIcon(size: 24), + trailing: _forwardIcon, + onTap: null, + ), + SettingOption( + title: S.of(context).userData, + leading: const UserCircleIcon(size: 24), + trailing: _forwardIcon, + onTap: null, + ), + if (context.read().state.openWallet?.walletType == WalletType.software) SettingOption( - title: S.of(context).settingsDeleteWallet, - leading: const XCircleIcon(size: 24), - onTap: () async { - bool? isLogout = await showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: (_) => const SettingsConfirmLogoutWalletSheet(), - ); - if (isLogout ?? false) { - await Future.delayed(const Duration(milliseconds: 300)); - if (context.mounted) { - context.read().add(const DeleteCurrentWalletEvent()); - } - } - }, + title: S.of(context).settingsWalletBackup, + leading: const KeySolidIcon(size: 24), + trailing: _forwardIcon, + onTap: () => context.push('/settings/seed'), ), - ], + ], + ), + ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 20), + child: Divider(color: RealUnitColors.neutral200), + ), + SettingsSections( + settings: [ + SettingOption( + title: S.of(context).settingsDeleteWallet, + leading: const XCircleIcon(size: 24), + onTap: () async { + bool? isLogout = await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => const SettingsConfirmLogoutWalletSheet(), + ); + if (isLogout ?? false) { + await Future.delayed(const Duration(milliseconds: 300)); + if (context.mounted) { + context.read().reset(); + context.read().add(const DeleteCurrentWalletEvent()); + } + } + }, ), ], ), - ), - ); + ], + ), + ), + ); } From 0ece43d48164abb5eb8072b8d50385dde19f49e8 Mon Sep 17 00:00:00 2001 From: TuanLamNguyen Date: Wed, 11 Feb 2026 10:53:59 +0100 Subject: [PATCH 2/5] refactor: consistency between SetupPinPage and VerifyPinPage. --- .../pin/bloc/setup_pin/setup_pin_cubit.dart | 10 +- .../pin/bloc/verify_pin/verify_pin_cubit.dart | 6 +- lib/screens/pin/constants/pin_constants.dart | 1 + lib/screens/pin/setup_pin_page.dart | 54 +++++++---- lib/screens/pin/verify_pin_page.dart | 43 ++++---- lib/widgets/number_pad.dart | 97 ++++++++++--------- 6 files changed, 115 insertions(+), 96 deletions(-) create mode 100644 lib/screens/pin/constants/pin_constants.dart diff --git a/lib/screens/pin/bloc/setup_pin/setup_pin_cubit.dart b/lib/screens/pin/bloc/setup_pin/setup_pin_cubit.dart index e4aee141..d7b939e4 100644 --- a/lib/screens/pin/bloc/setup_pin/setup_pin_cubit.dart +++ b/lib/screens/pin/bloc/setup_pin/setup_pin_cubit.dart @@ -2,6 +2,7 @@ import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:realunit_wallet/packages/repository/settings_repository.dart'; import 'package:realunit_wallet/packages/storage/secure_storage.dart'; +import 'package:realunit_wallet/screens/pin/constants/pin_constants.dart'; part 'setup_pin_state.dart'; @@ -13,17 +14,16 @@ class SetupPinCubit extends Cubit { final SecureStorage _secureStorage; final SettingsRepository _settingsRepository; - final int maxPinLength = 4; String? _createPin; void addDigit(int digit) { - if (state.currentPin.length >= maxPinLength) return; + if (state.currentPin.length >= pinLength) return; final newPin = '${state.currentPin}$digit'; emit(state.copyWith(currentPin: newPin, mismatch: false)); - if (newPin.length == maxPinLength) { + if (newPin.length == pinLength) { _onPinComplete(newPin); } } @@ -71,7 +71,5 @@ class SetupPinCubit extends Cubit { } } - void reset() { - emit(const SetupPinState()); - } + void reset() => emit(const SetupPinState()); } diff --git a/lib/screens/pin/bloc/verify_pin/verify_pin_cubit.dart b/lib/screens/pin/bloc/verify_pin/verify_pin_cubit.dart index 3f8b6a10..5b4f81a1 100644 --- a/lib/screens/pin/bloc/verify_pin/verify_pin_cubit.dart +++ b/lib/screens/pin/bloc/verify_pin/verify_pin_cubit.dart @@ -1,6 +1,7 @@ import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:realunit_wallet/packages/storage/secure_storage.dart'; +import 'package:realunit_wallet/screens/pin/constants/pin_constants.dart'; part 'verify_pin_state.dart'; @@ -8,12 +9,11 @@ class VerifyPinCubit extends Cubit { VerifyPinCubit(this._secureStorage) : super(const VerifyPinState(pin: '')); final SecureStorage _secureStorage; - final int maxPinLength = 4; void addDigit(int digit) { - if (state.pin.length == maxPinLength) return; + if (state.pin.length == pinLength) return; emit(state.copyWith(pin: '${state.pin}$digit')); - if (state.pin.length == maxPinLength) checkPin(); + if (state.pin.length == pinLength) checkPin(); } void deleteDigit() { diff --git a/lib/screens/pin/constants/pin_constants.dart b/lib/screens/pin/constants/pin_constants.dart new file mode 100644 index 00000000..41f95590 --- /dev/null +++ b/lib/screens/pin/constants/pin_constants.dart @@ -0,0 +1 @@ +const int pinLength = 4; diff --git a/lib/screens/pin/setup_pin_page.dart b/lib/screens/pin/setup_pin_page.dart index 4c512f60..ddc0fb6c 100644 --- a/lib/screens/pin/setup_pin_page.dart +++ b/lib/screens/pin/setup_pin_page.dart @@ -5,6 +5,7 @@ import 'package:realunit_wallet/packages/repository/settings_repository.dart'; import 'package:realunit_wallet/packages/storage/secure_storage.dart'; import 'package:realunit_wallet/screens/pin/bloc/auth/pin_auth_cubit.dart'; import 'package:realunit_wallet/screens/pin/bloc/setup_pin/setup_pin_cubit.dart'; +import 'package:realunit_wallet/screens/pin/constants/pin_constants.dart'; import 'package:realunit_wallet/screens/pin/widgets/pin_indicator.dart'; import 'package:realunit_wallet/styles/colors.dart'; import 'package:realunit_wallet/widgets/number_pad.dart'; @@ -31,17 +32,28 @@ class SetupPinView extends StatelessWidget { @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: RealUnitColors.brand700, - body: SafeArea( - child: BlocConsumer( - listener: (context, state) { - if (state.isComplete) { - getIt().onPinSetupComplete(); - } - }, - builder: (context, state) { - return Column( + return BlocConsumer( + listener: (context, state) { + if (state.isComplete) { + getIt().onPinSetupComplete(); + } + }, + builder: (context, state) { + return Scaffold( + appBar: AppBar( + leading: state.mode == SetupPinMode.confirm + ? IconButton( + icon: const Icon(Icons.arrow_back_rounded), + onPressed: switch (state.mode) { + SetupPinMode.create => null, + SetupPinMode.confirm => () => context.read().reset(), + }, + ) + : null, + ), + backgroundColor: RealUnitColors.brand700, + body: SafeArea( + child: Column( children: [ Expanded( child: Column( @@ -75,7 +87,7 @@ class SetupPinView extends StatelessWidget { const SizedBox(height: 40), PinIndicator( pinLength: state.currentPin.length, - expectedPinLength: context.read().maxPinLength, + expectedPinLength: pinLength, wrongPin: state.mismatch, ), if (state.mismatch) ...[ @@ -91,16 +103,18 @@ class SetupPinView extends StatelessWidget { ], ), ), - NumberPad( - onNumberPressed: (digit) => context.read().addDigit(digit), - onDeletePressed: () => context.read().deleteDigit(), + Expanded( + child: NumberPad( + onNumberPressed: (digit) => context.read().addDigit(digit), + onDeletePressed: () => context.read().deleteDigit(), + ), ), - const SizedBox(height: 40), + const SizedBox(height: 40.0), ], - ); - }, - ), - ), + ), + ), + ); + }, ); } } diff --git a/lib/screens/pin/verify_pin_page.dart b/lib/screens/pin/verify_pin_page.dart index 79aed47c..39017a66 100644 --- a/lib/screens/pin/verify_pin_page.dart +++ b/lib/screens/pin/verify_pin_page.dart @@ -5,6 +5,7 @@ import 'package:realunit_wallet/packages/storage/secure_storage.dart'; import 'package:realunit_wallet/screens/home/bloc/home_bloc.dart'; import 'package:realunit_wallet/screens/pin/bloc/auth/pin_auth_cubit.dart'; import 'package:realunit_wallet/screens/pin/bloc/verify_pin/verify_pin_cubit.dart'; +import 'package:realunit_wallet/screens/pin/constants/pin_constants.dart'; import 'package:realunit_wallet/screens/pin/widgets/forgot_pin_bottom_sheet.dart'; import 'package:realunit_wallet/screens/pin/widgets/pin_indicator.dart'; import 'package:realunit_wallet/styles/colors.dart'; @@ -30,6 +31,7 @@ class VerifyPinView extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( + appBar: AppBar(), backgroundColor: RealUnitColors.brand700, body: SafeArea( child: BlocConsumer( @@ -67,7 +69,7 @@ class VerifyPinView extends StatelessWidget { const SizedBox(height: 40), PinIndicator( pinLength: state.pin.length, - expectedPinLength: context.read().maxPinLength, + expectedPinLength: pinLength, wrongPin: state is VerifyPinFailure, ), if (state is VerifyPinFailure) ...[ @@ -83,28 +85,27 @@ class VerifyPinView extends StatelessWidget { ], ), ), - NumberPad( - onNumberPressed: (digit) => context.read().addDigit(digit), - onDeletePressed: () => context.read().deleteDigit(), + Expanded( + child: NumberPad( + onNumberPressed: (digit) => context.read().addDigit(digit), + onDeletePressed: () => context.read().deleteDigit(), + ), ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 26, vertical: 10), - child: TextButton( - onPressed: () async { - bool? isReset = await showModalBottomSheet( - context: context, - builder: (_) => const ForgotPinBottomSheet(), - ); - if (isReset ?? false) { - await Future.delayed(const Duration(milliseconds: 300)); - if (context.mounted) { - context.read().reset(); - context.read().add(const DeleteCurrentWalletEvent()); - } + TextButton( + onPressed: () async { + bool? isReset = await showModalBottomSheet( + context: context, + builder: (_) => const ForgotPinBottomSheet(), + ); + if (isReset ?? false) { + await Future.delayed(const Duration(milliseconds: 300)); + if (context.mounted) { + context.read().reset(); + context.read().add(const DeleteCurrentWalletEvent()); } - }, - child: const Text('Forgot PIN? Reset Wallet'), - ), + } + }, + child: const Text('Forgot PIN? Reset Wallet'), ), ], ); diff --git a/lib/widgets/number_pad.dart b/lib/widgets/number_pad.dart index ee5f29e0..4da63807 100644 --- a/lib/widgets/number_pad.dart +++ b/lib/widgets/number_pad.dart @@ -12,59 +12,64 @@ class NumberPad extends StatelessWidget { this.onDecimalPressed, }); - static const _buttonStyle = - TextStyle(fontSize: 25.0, fontWeight: FontWeight.w600, color: Colors.black); + static const _buttonStyle = TextStyle( + fontSize: 25.0, + fontWeight: FontWeight.w600, + color: Colors.black, + ); @override - Widget build(BuildContext context) => SizedBox( - height: 300, - child: GridView.count( - childAspectRatio: 2, - shrinkWrap: true, - crossAxisCount: 3, - physics: const NeverScrollableScrollPhysics(), - children: List.generate(12, (index) { - if (index == 9) { - if (onDecimalPressed == null) return Container(); - return InkWell( - onTap: onDecimalPressed, - child: const Center( - child: Text( - '.', - style: _buttonStyle, - textAlign: TextAlign.center, - ), - ), - ); - } else if (index == 10) { - index = 0; - } else if (index == 11) { - return InkWell( - onTap: onDeletePressed, - child: Semantics( - label: 'Delete', - button: true, - child: const Icon( - Icons.arrow_back_ios, - color: Colors.black, - ), - ), - ); - } else { - index++; - } - + Widget build(BuildContext context) => Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: GridView.count( + childAspectRatio: 2, + shrinkWrap: true, + crossAxisCount: 3, + physics: const NeverScrollableScrollPhysics(), + children: List.generate(12, (index) { + if (index == 9) { + if (onDecimalPressed == null) return Container(); return InkWell( - onTap: () => onNumberPressed(index), - child: Center( + onTap: onDecimalPressed, + child: const Center( child: Text( - '$index', + '.', style: _buttonStyle, textAlign: TextAlign.center, ), ), ); - }), - ), - ); + } else if (index == 10) { + index = 0; + } else if (index == 11) { + return InkWell( + onTap: onDeletePressed, + child: Semantics( + label: 'Delete', + button: true, + child: const Icon( + Icons.arrow_back_ios, + color: Colors.black, + ), + ), + ); + } else { + index++; + } + + return InkWell( + onTap: () => onNumberPressed(index), + child: Center( + child: Text( + '$index', + style: _buttonStyle, + textAlign: TextAlign.center, + ), + ), + ); + }), + ), + ), + ); } From 6419e3b1799c0f17221f9e6598c3a2c4a539c937 Mon Sep 17 00:00:00 2001 From: TuanLamNguyen Date: Wed, 11 Feb 2026 11:17:38 +0100 Subject: [PATCH 3/5] chore: localization. --- assets/languages/strings_de.arb | 9 +++++++++ assets/languages/strings_en.arb | 9 +++++++++ lib/screens/pin/setup_pin_page.dart | 15 ++++++++------- lib/screens/pin/verify_pin_page.dart | 21 +++++++++++---------- 4 files changed, 37 insertions(+), 17 deletions(-) diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb index ce6655a4..47debfe1 100644 --- a/assets/languages/strings_de.arb +++ b/assets/languages/strings_de.arb @@ -102,6 +102,15 @@ "pdf": "PDF", "personalData": "Persönliche Daten", "phoneNumber": "Telefonnummer", + "pinConfirm": "Bestätigen Sie Ihre PIN", + "pinConfirmDescription": "Geben Sie Ihre PIN zur Bestätigung erneut ein", + "pinConfirmFailed": "Die PINs stimmen nicht überein. Versuchen Sie es erneut.", + "pinCreate": "Erstellen Sie Ihre PIN", + "pinCreateDescription": "Geben Sie eine 4-stellige PIN ein, um Ihre Wallet zu sichern", + "pinForgotten": "PIN vergessen? Wallet zurücksetzen", + "pinVerify": "Geben Sie Ihre PIN ein", + "pinVerifyDescription": "Geben Sie Ihre PIN ein, um Ihre Wallet zu entsperren", + "pinVerifyFailed": "Die PIN ist falsch. Versuchen Sie es erneut.", "pleaseSelect": "Bitte auswählen", "portfolio": "Bestand", "postcodeAbr": "PLZ", diff --git a/assets/languages/strings_en.arb b/assets/languages/strings_en.arb index f8134d2e..686d52d5 100644 --- a/assets/languages/strings_en.arb +++ b/assets/languages/strings_en.arb @@ -102,6 +102,15 @@ "pdf": "PDF", "personalData": "Personal data", "phoneNumber": "Phone number", + "pinConfirm": "Confirm your pin", + "pinConfirmDescription": "Re-enter your PIN to confirm", + "pinConfirmFailed": "PINs do not match. Try again.", + "pinCreate": "Create your pin", + "pinCreateDescription": "Enter a 4-digit PIN to secure your wallet", + "pinForgotten": "Forgot PIN? Reset Wallet", + "pinVerify": "Enter your pin", + "pinVerifyDescription": "Enter your PIN to unlock your wallet", + "pinVerifyFailed": "PIN is wrong. Try again.", "pleaseSelect": "Please select", "portfolio": "Portfolio", "postcodeAbr": "Post code", diff --git a/lib/screens/pin/setup_pin_page.dart b/lib/screens/pin/setup_pin_page.dart index ddc0fb6c..c3110bbd 100644 --- a/lib/screens/pin/setup_pin_page.dart +++ b/lib/screens/pin/setup_pin_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:realunit_wallet/di.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; import 'package:realunit_wallet/packages/repository/settings_repository.dart'; import 'package:realunit_wallet/packages/storage/secure_storage.dart'; import 'package:realunit_wallet/screens/pin/bloc/auth/pin_auth_cubit.dart'; @@ -11,10 +12,10 @@ import 'package:realunit_wallet/styles/colors.dart'; import 'package:realunit_wallet/widgets/number_pad.dart'; class SetupPinPage extends StatelessWidget { - const SetupPinPage({super.key}); - static const route = '/pin/setup'; + const SetupPinPage({super.key}); + @override Widget build(BuildContext context) { return BlocProvider( @@ -61,8 +62,8 @@ class SetupPinView extends StatelessWidget { children: [ Text( switch (state.mode) { - SetupPinMode.create => 'Create your PIN', - SetupPinMode.confirm => 'Confirm your PIN', + SetupPinMode.create => S.of(context).pinCreate, + SetupPinMode.confirm => S.of(context).pinConfirm, }, style: const TextStyle( fontSize: 26, @@ -75,8 +76,8 @@ class SetupPinView extends StatelessWidget { const SizedBox(height: 8), Text( switch (state.mode) { - SetupPinMode.create => 'Enter a 4-digit PIN to secure your wallet', - SetupPinMode.confirm => 'Re-enter your PIN to confirm', + SetupPinMode.create => S.of(context).pinCreateDescription, + SetupPinMode.confirm => S.of(context).pinConfirmDescription, }, style: const TextStyle( fontSize: 14, @@ -93,7 +94,7 @@ class SetupPinView extends StatelessWidget { if (state.mismatch) ...[ const SizedBox(height: 16), Text( - 'PINs do not match. Try again.', + S.of(context).pinConfirmFailed, style: TextStyle( fontSize: 14, color: RealUnitColors.status.red600, diff --git a/lib/screens/pin/verify_pin_page.dart b/lib/screens/pin/verify_pin_page.dart index 39017a66..45d57ee1 100644 --- a/lib/screens/pin/verify_pin_page.dart +++ b/lib/screens/pin/verify_pin_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:realunit_wallet/di.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; import 'package:realunit_wallet/packages/storage/secure_storage.dart'; import 'package:realunit_wallet/screens/home/bloc/home_bloc.dart'; import 'package:realunit_wallet/screens/pin/bloc/auth/pin_auth_cubit.dart'; @@ -12,10 +13,10 @@ import 'package:realunit_wallet/styles/colors.dart'; import 'package:realunit_wallet/widgets/number_pad.dart'; class VerifyPinPage extends StatelessWidget { - const VerifyPinPage({super.key}); - static const route = '/pin/verify'; + const VerifyPinPage({super.key}); + @override Widget build(BuildContext context) => BlocProvider( create: (_) => VerifyPinCubit( @@ -47,9 +48,9 @@ class VerifyPinView extends StatelessWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Text( - 'Enter your PIN', - style: TextStyle( + Text( + S.of(context).pinVerify, + style: const TextStyle( fontSize: 26, fontWeight: FontWeight.w700, color: RealUnitColors.realUnitBlack, @@ -58,9 +59,9 @@ class VerifyPinView extends StatelessWidget { ), ), const SizedBox(height: 8), - const Text( - 'Enter your PIN to unlock the wallet', - style: TextStyle( + Text( + S.of(context).pinVerifyDescription, + style: const TextStyle( fontSize: 14, color: RealUnitColors.neutral500, height: 18 / 14, @@ -75,7 +76,7 @@ class VerifyPinView extends StatelessWidget { if (state is VerifyPinFailure) ...[ const SizedBox(height: 16), Text( - 'Wrong PIN. Try again.', + S.of(context).pinVerifyFailed, style: TextStyle( fontSize: 14, color: RealUnitColors.status.red600, @@ -105,7 +106,7 @@ class VerifyPinView extends StatelessWidget { } } }, - child: const Text('Forgot PIN? Reset Wallet'), + child: Text(S.of(context).pinForgotten), ), ], ); From 9db1e2d80b6050d5e03b7853a82acb9da770a512 Mon Sep 17 00:00:00 2001 From: TuanLamNguyen Date: Wed, 11 Feb 2026 15:27:54 +0100 Subject: [PATCH 4/5] fix: correct check of elapsed backgroundTime. --- lib/main.dart | 10 ++++++---- lib/screens/pin/bloc/auth/pin_auth_cubit.dart | 3 ++- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index ea5aac5f..7928ab2d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -80,21 +80,23 @@ class _WalletAppState extends State { case AppLifecycleState.paused: _onPaused(); } + developer.log(state.name, name: 'AppLifecycleListener'); } - void _onDetached() => developer.log('detached'); + void _onDetached() {} void _onResumed() { getIt().onAppResumed(); getIt().updateBalances(getIt().primaryAddress); } - void _onInactive() => developer.log('inactive', name: 'AppLifecycleListener'); + void _onInactive() {} - void _onHidden() => developer.log('hidden', name: 'AppLifecycleListener'); + void _onHidden() { + getIt().onAppHidden(); + } void _onPaused() { - getIt().onAppPaused(); getIt().cancelSync(); } diff --git a/lib/screens/pin/bloc/auth/pin_auth_cubit.dart b/lib/screens/pin/bloc/auth/pin_auth_cubit.dart index c6f5f7cb..e42c4733 100644 --- a/lib/screens/pin/bloc/auth/pin_auth_cubit.dart +++ b/lib/screens/pin/bloc/auth/pin_auth_cubit.dart @@ -34,7 +34,7 @@ class PinAuthCubit extends Cubit { void onPinVerified() => emit(state.copyWith(isPinVerified: true)); - void onAppPaused() => _lastBackgroundTime = DateTime.now(); + void onAppHidden() => _lastBackgroundTime ??= DateTime.now(); void onAppResumed() { if (!state.isPinSetup) return; @@ -46,6 +46,7 @@ class PinAuthCubit extends Cubit { if (elapsed >= _lockoutDuration) { emit(state.copyWith(isPinVerified: false)); } + _lastBackgroundTime = null; } void reset() { From e45a097cb1560ecba550d2c953cb6180ad8c5782 Mon Sep 17 00:00:00 2001 From: TuanLamNguyen Date: Thu, 12 Feb 2026 11:32:29 +0100 Subject: [PATCH 5/5] chore: localization and minor improvements. --- assets/languages/strings_de.arb | 3 +++ assets/languages/strings_en.arb | 3 +++ lib/screens/pin/verify_pin_page.dart | 4 +-- .../pin/widgets/forgot_pin_bottom_sheet.dart | 25 ++++++++----------- 4 files changed, 19 insertions(+), 16 deletions(-) diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb index 47debfe1..8c8f4481 100644 --- a/assets/languages/strings_de.arb +++ b/assets/languages/strings_de.arb @@ -108,6 +108,8 @@ "pinCreate": "Erstellen Sie Ihre PIN", "pinCreateDescription": "Geben Sie eine 4-stellige PIN ein, um Ihre Wallet zu sichern", "pinForgotten": "PIN vergessen? Wallet zurücksetzen", + "pinForgottenDescription": "Durch diese Aktion werden Ihre Wallet und alle zugehörigen Daten gelöscht. Stellen Sie sicher, dass Sie Ihre Wiederherstellungsphrase gesichert haben.", + "pinForgottenTitle": "Wallet wird zurückgesetzt", "pinVerify": "Geben Sie Ihre PIN ein", "pinVerifyDescription": "Geben Sie Ihre PIN ein, um Ihre Wallet zu entsperren", "pinVerifyFailed": "Die PIN ist falsch. Versuchen Sie es erneut.", @@ -146,6 +148,7 @@ "registerPhoneNumberTooShort": "Telefonnummer ist zu kurz", "registrationRequired": "Registrierung erforderlich", "registrationRequiredDescription": "Um REALU zu kaufen, müssen Sie Ihre Wallet registrieren. Dabei entsteht eine Geschäftsbeziehung mit RealUnit AG und DFX AG.", + "reset": "Zurücksetzen", "residence": "Residenz", "restoreWallet": "Wallet wiederherstellen", "restoreWalletFromSeed": "Wallet mit Seed wiederherstellen", diff --git a/assets/languages/strings_en.arb b/assets/languages/strings_en.arb index 686d52d5..7ccfa62d 100644 --- a/assets/languages/strings_en.arb +++ b/assets/languages/strings_en.arb @@ -108,6 +108,8 @@ "pinCreate": "Create your pin", "pinCreateDescription": "Enter a 4-digit PIN to secure your wallet", "pinForgotten": "Forgot PIN? Reset Wallet", + "pinForgottenDescription": "This action will delete your wallet and all associated data. Make sure you have backed up your recovery phrase.", + "pinForgottenTitle": "Wallet will be reset", "pinVerify": "Enter your pin", "pinVerifyDescription": "Enter your PIN to unlock your wallet", "pinVerifyFailed": "PIN is wrong. Try again.", @@ -146,6 +148,7 @@ "registerPhoneNumberTooShort": "Phone number is too short", "registrationRequired": "Registration required", "registrationRequiredDescription": "To purchase REALU, you must register your wallet. This establishes a business relationship with RealUnit AG and DFX AG.", + "reset": "Reset", "residence": "Residence", "restoreWallet": "Restore wallet", "restoreWalletFromSeed": "Restore wallet with seed", diff --git a/lib/screens/pin/verify_pin_page.dart b/lib/screens/pin/verify_pin_page.dart index 45d57ee1..63e7504d 100644 --- a/lib/screens/pin/verify_pin_page.dart +++ b/lib/screens/pin/verify_pin_page.dart @@ -94,11 +94,11 @@ class VerifyPinView extends StatelessWidget { ), TextButton( onPressed: () async { - bool? isReset = await showModalBottomSheet( + bool isReset = await showModalBottomSheet( context: context, builder: (_) => const ForgotPinBottomSheet(), ); - if (isReset ?? false) { + if (isReset) { await Future.delayed(const Duration(milliseconds: 300)); if (context.mounted) { context.read().reset(); diff --git a/lib/screens/pin/widgets/forgot_pin_bottom_sheet.dart b/lib/screens/pin/widgets/forgot_pin_bottom_sheet.dart index bdc047a0..35a84eae 100644 --- a/lib/screens/pin/widgets/forgot_pin_bottom_sheet.dart +++ b/lib/screens/pin/widgets/forgot_pin_bottom_sheet.dart @@ -25,10 +25,10 @@ class ForgotPinBottomSheet extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - const Text( - 'Reset Wallet', + Text( + S.of(context).pinForgottenTitle, textAlign: TextAlign.center, - style: TextStyle( + style: const TextStyle( fontSize: 26, fontWeight: FontWeight.w700, height: 30 / 26, @@ -36,11 +36,10 @@ class ForgotPinBottomSheet extends StatelessWidget { ), ), const SizedBox(height: 8), - const Text( - 'This will delete your wallet and all associated data. ' - 'Make sure you have your recovery phrase backed up.', + Text( + S.of(context).pinForgottenDescription, textAlign: TextAlign.center, - style: TextStyle( + style: const TextStyle( color: RealUnitColors.neutral500, fontSize: 14, height: 18 / 14, @@ -61,7 +60,7 @@ class ForgotPinBottomSheet extends StatelessWidget { RealUnitColors.realUnitBlack, ), ), - onPressed: () => context.pop(), + onPressed: () => context.pop(false), child: Text( S.of(context).close, style: const TextStyle( @@ -75,12 +74,10 @@ class ForgotPinBottomSheet extends StatelessWidget { ), Expanded( child: FilledButton( - onPressed: () { - Navigator.of(context).pop(true); - }, - child: const Text( - 'Reset', - style: TextStyle( + onPressed: () => context.pop(true), + child: Text( + S.of(context).reset, + style: const TextStyle( fontSize: 16, height: 20 / 16, fontWeight: FontWeight.w600,