Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions assets/languages/strings_de.arb
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@
"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.",
"pinVerifying": "Anmeldung…",
"pinVerifyLocked": "Zu viele Fehlversuche. Nutzen Sie 'PIN vergessen?', um zurückzusetzen.",
"pinVerifyLockedTemporarily": "Zu viele Fehlversuche. Versuchen Sie es in ${remaining} erneut.",
"pinVerifySeedDescription": "Geben Sie Ihre PIN ein, um Ihre Seed-Phrase anzuzeigen",
Expand Down
1 change: 1 addition & 0 deletions assets/languages/strings_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@
"pinVerify": "Enter your pin",
"pinVerifyDescription": "Enter your PIN to unlock your wallet",
"pinVerifyFailed": "PIN is wrong. Try again.",
"pinVerifying": "Signing in…",
"pinVerifyLocked": "Too many failed attempts. Use 'Forgot PIN?' to reset.",
"pinVerifyLockedTemporarily": "Too many failed attempts. Try again in ${remaining}.",
"pinVerifySeedDescription": "Enter your PIN to view your seed phrase",
Expand Down
31 changes: 20 additions & 11 deletions lib/screens/pin/bloc/verify_pin/verify_pin_cubit.dart
Original file line number Diff line number Diff line change
Expand Up @@ -33,18 +33,27 @@ class VerifyPinCubit extends Cubit<VerifyPinState> {
}

Future<void> checkPin() async {
final isCorrect = await _secureStorage.verifyPin(state.pin);
if (isCorrect) {
if (enableLockout) await _secureStorage.resetPinLockout();
emit(const VerifyPinSuccess());
} else {
if (!enableLockout) {
emit(const VerifyPinFailure(failedAttempts: 0));
return;
final pin = state.pin;
final previousAttempts = state.failedAttempts;
emit(VerifyPinVerifying(pin: pin, failedAttempts: previousAttempts));
try {
final isCorrect = await _secureStorage.verifyPin(pin);
if (isCorrect) {
if (enableLockout) await _secureStorage.resetPinLockout();
emit(const VerifyPinSuccess());
} else {
if (!enableLockout) {
emit(const VerifyPinFailure(failedAttempts: 0));
return;
}
final attempts = await _secureStorage.getPinFailedAttempts() + 1;
await _secureStorage.setPinFailedAttempts(attempts);
await _emitLockState(attempts);
}
final attempts = await _secureStorage.getPinFailedAttempts() + 1;
await _secureStorage.setPinFailedAttempts(attempts);
await _emitLockState(attempts);
} catch (_) {
// A hash/storage failure must not strand the user on the spinner with no
// number pad. Restore the input so they can retry instead of dead-ending.
emit(VerifyPinState(failedAttempts: previousAttempts));
}
}

Expand Down
4 changes: 4 additions & 0 deletions lib/screens/pin/bloc/verify_pin/verify_pin_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ class VerifyPinState extends Equatable {
List<Object?> get props => [pin, failedAttempts];
}

class VerifyPinVerifying extends VerifyPinState {
const VerifyPinVerifying({required super.pin, super.failedAttempts});
}

class VerifyPinSuccess extends VerifyPinState {
const VerifyPinSuccess() : super(pin: '');
}
Expand Down
45 changes: 38 additions & 7 deletions lib/screens/pin/verify_pin_page.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'dart:async';

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:realunit_wallet/generated/i18n.dart';
Expand Down Expand Up @@ -98,6 +99,9 @@ class _VerifyPinViewState extends State<VerifyPinView> {
},
builder: (context, state) {
final isLocked = state is VerifyPinTemporarilyLocked || state is VerifyPinLocked;
// Covers both the PIN-hash check (VerifyPinVerifying) and the subsequent
// wallet load that runs after success while this screen is still on top.
final isVerifying = state is VerifyPinVerifying || state is VerifyPinSuccess;

return Scaffold(
appBar: AppBar(),
Expand Down Expand Up @@ -139,7 +143,7 @@ class _VerifyPinViewState extends State<VerifyPinView> {
spacing: 16.0,
children: [
PinIndicator(
pinLength: state.pin.length,
pinLength: isVerifying ? pinLength : state.pin.length,
expectedPinLength: pinLength,
wrongPin: state is VerifyPinFailure || isLocked,
),
Expand Down Expand Up @@ -174,13 +178,16 @@ class _VerifyPinViewState extends State<VerifyPinView> {
),
),
const Spacer(),
IgnorePointer(
ignoring: isLocked,
child: NumberPad(
onNumberPressed: context.read<VerifyPinCubit>().addDigit,
onDeletePressed: context.read<VerifyPinCubit>().deleteDigit,
if (isVerifying)
const _VerifyingIndicator()
else
IgnorePointer(
ignoring: isLocked,
child: NumberPad(
onNumberPressed: context.read<VerifyPinCubit>().addDigit,
onDeletePressed: context.read<VerifyPinCubit>().deleteDigit,
),
),
),
if (widget.bottom != null) widget.bottom! else const SizedBox(height: 60.0),
],
),
Expand Down Expand Up @@ -257,3 +264,27 @@ class _ForgotPinButton extends StatelessWidget {
),
);
}

class _VerifyingIndicator extends StatelessWidget {
const _VerifyingIndicator();

@override
Widget build(BuildContext context) => SizedBox(
// Matches the NumberPad footprint (4 rows of ~68px) so swapping it in for
// the spinner does not shift the PIN dots above it.
height: 272,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CupertinoActivityIndicator(radius: 16),
const SizedBox(height: 16),
Text(
S.of(context).pinVerifying,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: RealUnitColors.neutral500,
),
),
],
),
);
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 20 additions & 0 deletions test/goldens/screens/pin/verify_pin_golden_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -50,5 +50,25 @@ void main() {
),
),
);

goldenTest(
'verifying state replaces the number pad with a spinner',
fileName: 'verify_pin_page_verifying',
// CupertinoActivityIndicator never settles; pump once to capture the
// initial frame instead of letting pumpAndSettle hang.
pumpBeforeTest: pumpOnce,
constraints: const BoxConstraints.tightFor(width: 390, height: 844),
builder: () {
final cubit = _MockVerifyPinCubit();
when(() => cubit.state).thenReturn(const VerifyPinVerifying(pin: '123456'));
when(() => cubit.checkBiometricAvailability()).thenAnswer((_) async {});
return wrapForGolden(
BlocProvider<VerifyPinCubit>.value(
value: cubit,
child: VerifyPinView(onAuthenticated: () {}),
),
);
},
);
});
}
22 changes: 22 additions & 0 deletions test/screens/pin/bloc/verify_pin/verify_pin_state_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,28 @@ void main() {
});
});

group('VerifyPinVerifying', () {
test('carries the entered pin and is equal for same pin + attempts', () {
final a = VerifyPinVerifying(pin: '123456');
final b = VerifyPinVerifying(pin: '123456');
expect(a, equals(b));
expect(a.hashCode, b.hashCode);
expect(a.pin, '123456');
expect(a.failedAttempts, 0);
});

test('different pin is unequal', () {
final a = VerifyPinVerifying(pin: '123456');
final b = VerifyPinVerifying(pin: '654321');
expect(a, isNot(equals(b)));
});

test('preserves failedAttempts', () {
final a = VerifyPinVerifying(pin: '123456', failedAttempts: 3);
expect(a.failedAttempts, 3);
});
});

group('VerifyPinFailure', () {
test('same failedAttempts is equal', () {
final a = VerifyPinFailure(failedAttempts: 2);
Expand Down
33 changes: 33 additions & 0 deletions test/screens/pin/verify_pin_cubit_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,24 @@ void main() {
verify(() => secureStorage.resetPinLockout()).called(1);
});

blocTest<VerifyPinCubit, VerifyPinState>(
'emits VerifyPinVerifying (carrying the full pin) before VerifyPinSuccess',
build: build,
setUp: () =>
when(() => secureStorage.verifyPin(any())).thenAnswer((_) async => true),
act: (cubit) => addPin(cubit, '123456'),
expect: () => [
const VerifyPinState(pin: '1'),
const VerifyPinState(pin: '12'),
const VerifyPinState(pin: '123'),
const VerifyPinState(pin: '1234'),
const VerifyPinState(pin: '12345'),
const VerifyPinState(pin: '123456'),
const VerifyPinVerifying(pin: '123456'),
const VerifyPinSuccess(),
],
);

test('wrong pin (1st attempt) with lockout on emits VerifyPinFailure', () async {
when(() => secureStorage.verifyPin(any())).thenAnswer((_) async => false);
when(() => secureStorage.getPinFailedAttempts()).thenAnswer((_) async => 0);
Expand Down Expand Up @@ -171,6 +189,21 @@ void main() {
// Permanent lockout does NOT write a temporary lockedUntil.
verifyNever(() => secureStorage.setPinLockedUntil(any()));
});

test('a verifyPin failure recovers to a usable state instead of a stuck spinner', () async {
when(() => secureStorage.verifyPin(any())).thenThrow(Exception('hash failure'));
final cubit = build();
// After the spinner, recovery emits a plain VerifyPinState (input reset)
// so the number pad returns — never a permanent VerifyPinVerifying.
final recovered =
cubit.stream.firstWhere((s) => s.runtimeType == VerifyPinState && s.pin.isEmpty);

addPin(cubit, '123456');
await recovered.timeout(const Duration(seconds: 30));

expect(cubit.state.runtimeType, VerifyPinState);
expect(cubit.state.pin, isEmpty);
});
});

group('onLockExpired', () {
Expand Down
35 changes: 34 additions & 1 deletion test/screens/pin/verify_pin_page_test.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import 'package:bloc_test/bloc_test.dart';
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:get_it/get_it.dart';
Expand All @@ -9,6 +9,7 @@ import 'package:realunit_wallet/packages/service/biometric_service.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/verify_pin/verify_pin_cubit.dart';
import 'package:realunit_wallet/screens/pin/constants/pin_constants.dart';
import 'package:realunit_wallet/screens/pin/verify_pin_page.dart';
import 'package:realunit_wallet/screens/pin/widgets/pin_indicator.dart';
import 'package:realunit_wallet/setup/di.dart';
Expand Down Expand Up @@ -158,6 +159,38 @@ void main() {
);
});

testWidgets('shows a loading indicator and hides the number pad while verifying', (
tester,
) async {
when(() => verifyPinCubit.state).thenReturn(const VerifyPinVerifying(pin: '123456'));

await tester.pumpApp(
buildSubject(VerifyPinView(onAuthenticated: onAuthenticated)),
);

expect(find.byType(CupertinoActivityIndicator), findsOne);
expect(find.text(S.current.pinVerifying), findsOne);
expect(find.byType(NumberPad), findsNothing);
// Dots stay filled during the wait so the screen does not look reset.
expect(
(tester.widget(find.byType(PinIndicator)) as PinIndicator).pinLength,
pinLength,
);
});

testWidgets('keeps the loading indicator after success while the wallet loads', (
tester,
) async {
when(() => verifyPinCubit.state).thenReturn(const VerifyPinSuccess());

await tester.pumpApp(
buildSubject(VerifyPinView(onAuthenticated: onAuthenticated)),
);

expect(find.byType(CupertinoActivityIndicator), findsOne);
expect(find.byType(NumberPad), findsNothing);
});

group('$BlocListener', () {
testWidgets('triggers onPinVerified if verification is successful', (tester) async {
whenListen(
Expand Down
Loading