From 64c8cde47fd54f1174f526734ea26d4014c0a281 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 9 Jun 2026 23:37:35 +0200 Subject: [PATCH 1/5] Merge pull request #720 from RealUnitCH/fix/bitbox-connect-on-link-wallet fix(kyc): open BitBox connect sheet when linking shareholder wallet --- .../cubits/kyc_link_wallet_cubit.dart | 14 +- .../cubits/kyc_link_wallet_state.dart | 15 ++ .../link_wallet/kyc_link_wallet_page.dart | 60 +++-- .../kyc_link_wallet_page_bitbox_required.png | Bin 0 -> 26126 bytes .../kyc/kyc_link_wallet_golden_test.dart | 25 ++- .../link_wallet_connect_flow_test.dart | 206 ++++++++++++++++++ .../kyc_link_wallet_cubit_test.dart | 55 +++-- .../kyc_link_wallet_page_test.dart | 167 +++++++++++++- 8 files changed, 495 insertions(+), 47 deletions(-) create mode 100644 test/goldens/screens/kyc/goldens/macos/kyc_link_wallet_page_bitbox_required.png create mode 100644 test/integration/link_wallet_connect_flow_test.dart diff --git a/lib/screens/kyc/steps/link_wallet/cubits/kyc_link_wallet_cubit.dart b/lib/screens/kyc/steps/link_wallet/cubits/kyc_link_wallet_cubit.dart index 1019dc79..a6cd9161 100644 --- a/lib/screens/kyc/steps/link_wallet/cubits/kyc_link_wallet_cubit.dart +++ b/lib/screens/kyc/steps/link_wallet/cubits/kyc_link_wallet_cubit.dart @@ -15,10 +15,8 @@ part 'kyc_link_wallet_state.dart'; class KycLinkWalletCubit extends Cubit { final RealUnitRegistrationService _registrationService; - KycLinkWalletCubit( - RealUnitRegistrationService registrationService, - RealUnitUserDataDto userData, - ) : _registrationService = registrationService, + KycLinkWalletCubit(RealUnitRegistrationService registrationService, RealUnitUserDataDto userData) + : _registrationService = registrationService, super(KycLinkWalletReady(userData)); Future submit(RealUnitUserDataDto userData) async { @@ -26,10 +24,14 @@ class KycLinkWalletCubit extends Cubit { emit(KycLinkWalletSubmitting(userData)); await _registrationService.registerWallet(userData); emit(const KycLinkWalletSuccess()); - } on BitboxNotConnectedException catch (e) { - emit(KycLinkWalletFailure(e.toString(), cause: e)); + } on BitboxNotConnectedException { + emit(KycLinkWalletBitboxRequired(userData)); } catch (e) { emit(KycLinkWalletFailure(e.toString(), cause: e)); } } + + /// Re-runs registration after the BitBox connection was established via the + /// `ConnectBitboxPage` sheet. Mirror of `KycRegistrationSubmitCubit.retrySubmit`. + Future retrySubmit(RealUnitUserDataDto userData) => submit(userData); } diff --git a/lib/screens/kyc/steps/link_wallet/cubits/kyc_link_wallet_state.dart b/lib/screens/kyc/steps/link_wallet/cubits/kyc_link_wallet_state.dart index 84823727..b68a822a 100644 --- a/lib/screens/kyc/steps/link_wallet/cubits/kyc_link_wallet_state.dart +++ b/lib/screens/kyc/steps/link_wallet/cubits/kyc_link_wallet_state.dart @@ -38,3 +38,18 @@ class KycLinkWalletFailure extends KycLinkWalletState { @override List get props => [message, cause]; } + +/// Emitted when `registerWallet` aborts because the BitBox is not connected. +/// The page reacts by opening the `ConnectBitboxPage` sheet and, on a +/// successful connection, calls `retrySubmit`. Carries `userData` so the page +/// renders the idle confirm body underneath the sheet (and again if the user +/// dismisses it without connecting). Mirror of +/// `KycRegistrationSubmitBitboxRequired`. +class KycLinkWalletBitboxRequired extends KycLinkWalletState { + final RealUnitUserDataDto userData; + + const KycLinkWalletBitboxRequired(this.userData); + + @override + List get props => [userData]; +} diff --git a/lib/screens/kyc/steps/link_wallet/kyc_link_wallet_page.dart b/lib/screens/kyc/steps/link_wallet/kyc_link_wallet_page.dart index 79f82e72..1a854efa 100644 --- a/lib/screens/kyc/steps/link_wallet/kyc_link_wallet_page.dart +++ b/lib/screens/kyc/steps/link_wallet/kyc_link_wallet_page.dart @@ -1,10 +1,13 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; import 'package:realunit_wallet/generated/i18n.dart'; import 'package:realunit_wallet/packages/service/app_store.dart'; import 'package:realunit_wallet/packages/service/dfx/models/user/dto/real_unit_user_data_dto.dart'; import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_service.dart'; +import 'package:realunit_wallet/screens/hardware_connect_bitbox/connect_bitbox_page.dart'; +import 'package:realunit_wallet/screens/home/bloc/home_bloc.dart'; import 'package:realunit_wallet/screens/kyc/cubits/kyc/kyc_cubit.dart'; import 'package:realunit_wallet/screens/kyc/steps/link_wallet/cubits/kyc_link_wallet_cubit.dart'; import 'package:realunit_wallet/setup/di.dart'; @@ -27,10 +30,7 @@ class KycLinkWalletPage extends StatelessWidget { return const _LinkWalletMissingUserDataPage(); } return BlocProvider( - create: (_) => KycLinkWalletCubit( - getIt(), - data, - ), + create: (_) => KycLinkWalletCubit(getIt(), data), child: const KycLinkWalletView(), ); } @@ -45,8 +45,10 @@ class KycLinkWalletView extends StatelessWidget { appBar: AppBar(title: Text(S.of(context).kycLinkWalletTitle)), body: BlocConsumer( listenWhen: (_, current) => - current is KycLinkWalletSuccess || current is KycLinkWalletFailure, - listener: (context, state) { + current is KycLinkWalletSuccess || + current is KycLinkWalletFailure || + current is KycLinkWalletBitboxRequired, + listener: (context, state) async { if (state is KycLinkWalletSuccess) { // Re-fetch routing state from the API. The wallet is now in the // Aktionariat share register, so `getRegistrationInfo` will return @@ -62,6 +64,26 @@ class KycLinkWalletView extends StatelessWidget { ), ); } + if (state is KycLinkWalletBitboxRequired) { + // No BitBox connected when registering the wallet — open the + // shared connect sheet instead of dead-ending on an error, then + // retry registration once the device is linked. Mirror of + // `KycRegistrationPage`. + final userData = state.userData; + final result = await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => ConnectBitboxPage( + onFinish: (wallet) { + context.read().add(SyncWalletServicesEvent(wallet)); + context.pop(true); + }, + ), + ); + if (context.mounted && result == true) { + context.read().retrySubmit(userData); + } + } }, builder: (context, state) => switch (state) { KycLinkWalletReady(:final userData) => _LinkWalletBody( @@ -72,6 +94,13 @@ class KycLinkWalletView extends StatelessWidget { userData: userData, isSubmitting: true, ), + // BitBox required: the listener drives the connect sheet on top of + // this frame; render the idle confirm body so the user lands back on + // the submit button if they dismiss the sheet without connecting. + KycLinkWalletBitboxRequired(:final userData) => _LinkWalletBody( + userData: userData, + isSubmitting: false, + ), // Success: the Bloc listener kicks off `checkKyc` which transitions // the parent KycCubit, so this branch is a transient frame. Render // the spinner to avoid a flash of the form. @@ -104,14 +133,8 @@ class _LinkWalletBody extends StatelessWidget { style: Theme.of(context).textTheme.bodyMedium, textAlign: TextAlign.center, ), - _LinkWalletInfoRow( - label: S.of(context).name, - value: userData.name, - ), - _LinkWalletInfoRow( - label: S.of(context).walletAddress, - value: walletAddress, - ), + _LinkWalletInfoRow(label: S.of(context).name, value: userData.name), + _LinkWalletInfoRow(label: S.of(context).walletAddress, value: walletAddress), const Spacer(), AppFilledButton( state: isSubmitting ? FilledButtonState.loading : FilledButtonState.idle, @@ -142,14 +165,9 @@ class _LinkWalletInfoRow extends StatelessWidget { children: [ Text( label, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: RealUnitColors.neutral500, - ), - ), - Text( - value, - style: Theme.of(context).textTheme.bodyLarge, + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: RealUnitColors.neutral500), ), + Text(value, style: Theme.of(context).textTheme.bodyLarge), ], ); } diff --git a/test/goldens/screens/kyc/goldens/macos/kyc_link_wallet_page_bitbox_required.png b/test/goldens/screens/kyc/goldens/macos/kyc_link_wallet_page_bitbox_required.png new file mode 100644 index 0000000000000000000000000000000000000000..34d7ea649d26dd8809c945f93cfeb1d402d18d91 GIT binary patch literal 26126 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 literal 0 HcmV?d00001 diff --git a/test/goldens/screens/kyc/kyc_link_wallet_golden_test.dart b/test/goldens/screens/kyc/kyc_link_wallet_golden_test.dart index a044ac53..bde6d850 100644 --- a/test/goldens/screens/kyc/kyc_link_wallet_golden_test.dart +++ b/test/goldens/screens/kyc/kyc_link_wallet_golden_test.dart @@ -13,8 +13,7 @@ import 'package:realunit_wallet/screens/kyc/steps/link_wallet/kyc_link_wallet_pa import '../../../helper/helper.dart'; -class _MockKycLinkWalletCubit extends MockCubit - implements KycLinkWalletCubit {} +class _MockKycLinkWalletCubit extends MockCubit implements KycLinkWalletCubit {} class _MockKycCubit extends MockCubit implements KycCubit {} @@ -83,5 +82,27 @@ void main() { ); }, ); + + // Locks the visual contract for the BitBox-required state: it must render + // the idle confirm body (so dismissing the connect sheet lands the user + // back on the submit button), never a spinner or error dead-end. The + // connect sheet itself is baselined separately under hardware_connect_bitbox. + goldenTest( + 'bitbox required renders the idle confirm body', + fileName: 'kyc_link_wallet_page_bitbox_required', + constraints: phoneConstraints, + builder: () { + when(() => linkCubit.state).thenReturn(const KycLinkWalletBitboxRequired(_userData)); + return wrapForGolden( + MultiBlocProvider( + providers: [ + BlocProvider.value(value: kycCubit), + BlocProvider.value(value: linkCubit), + ], + child: const KycLinkWalletView(), + ), + ); + }, + ); }); } diff --git a/test/integration/link_wallet_connect_flow_test.dart b/test/integration/link_wallet_connect_flow_test.dart new file mode 100644 index 00000000..df16208d --- /dev/null +++ b/test/integration/link_wallet_connect_flow_test.dart @@ -0,0 +1,206 @@ +// Cross-layer integration tests for the BitBox-gated "add wallet" (link_wallet) +// KYC step. +// +// These stitch the real `KycLinkWalletCubit` together with a real +// `RealUnitRegistrationService`, the real `Eip712Signer`, a +// `FakeBitboxCredentials` standing in for the hardware-wallet sign boundary, +// and a `MockClient` HTTP boundary — only the device transport and the network +// are faked, the orchestration runs through production code. +// +// They pin the seam the unit/widget suites only cover in isolation: a +// disconnected BitBox must surface as `KycLinkWalletBitboxRequired` (never a +// dead-end) WITHOUT touching the API, and once the device connects, +// `retrySubmit` must drive `registerWallet` (sign + POST) to a completed +// registration. +// +// The pairing ceremony itself (scan / init / channel-hash / confirm) is the +// subject of `connect_bitbox_flow_test.dart`; here we flip +// `FakeBitboxCredentials.behavior` from `disconnect` to `success` to model +// "the user connected the BitBox via that ceremony". + +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/config/api_config.dart'; +import 'package:realunit_wallet/packages/config/network_mode.dart'; +import 'package:realunit_wallet/packages/repository/cache_repository.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/registration/kyc/kyc_personal_data.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/user/dto/real_unit_user_data_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_service.dart'; +import 'package:realunit_wallet/packages/service/session_cache.dart'; +import 'package:realunit_wallet/packages/service/wallet_service.dart'; +import 'package:realunit_wallet/packages/wallet/wallet.dart'; +import 'package:realunit_wallet/packages/wallet/wallet_account.dart'; +import 'package:realunit_wallet/screens/kyc/steps/link_wallet/cubits/kyc_link_wallet_cubit.dart'; + +import '../helper/fake_bitbox_credentials.dart'; + +class _MockAppStore extends Mock implements AppStore {} + +class _MockWallet extends Mock implements AWallet {} + +class _MockAccount extends Mock implements AWalletAccount {} + +class _MockCacheRepository extends Mock implements CacheRepository {} + +class _MockWalletService extends Mock implements WalletService {} + +const _userData = RealUnitUserDataDto( + email: 'a@b.com', + name: 'Ada Lovelace', + type: 'HUMAN', + phoneNumber: '+41 79 000 00 00', + birthday: '1815-12-10', + nationality: 'CH', + addressStreet: 'Bahnhofstrasse 1', + addressPostalCode: '8000', + addressCity: 'Zurich', + addressCountry: 'CH', + swissTaxResidence: true, + lang: 'de', + kycData: KycPersonalData( + accountType: KycAccountType.personal, + firstName: 'Ada', + lastName: 'Lovelace', + phone: '+41 79 000 00 00', + address: KycAddress(street: 'Bahnhofstrasse', zip: '8000', city: 'Zurich', country: 41), + ), +); + +void main() { + late _MockAppStore appStore; + late _MockWallet wallet; + late _MockAccount account; + late _MockWalletService walletService; + late SessionCache session; + late FakeBitboxCredentials credentials; + + setUp(() { + appStore = _MockAppStore(); + wallet = _MockWallet(); + account = _MockAccount(); + walletService = _MockWalletService(); + session = SessionCache(_MockCacheRepository()); + session.setAuthToken('jwt-1'); + // signDelay zero keeps the ceremony synchronous-ish for tight assertions. + credentials = FakeBitboxCredentials(signDelay: Duration.zero); + + when(() => appStore.apiConfig).thenReturn(const ApiConfig(networkMode: NetworkMode.mainnet)); + when(() => appStore.sessionCache).thenReturn(session); + when(() => appStore.wallet).thenReturn(wallet); + when(() => wallet.primaryAccount).thenReturn(account); + when(() => account.primaryAddress).thenReturn(credentials); + when(() => walletService.ensureCurrentWalletUnlocked()).thenAnswer((_) async {}); + when(() => walletService.lockCurrentWallet()).thenAnswer((_) async {}); + }); + + KycLinkWalletCubit buildCubit(http.Client client) { + when(() => appStore.httpClient).thenReturn(client); + return KycLinkWalletCubit(RealUnitRegistrationService(appStore, walletService), _userData); + } + + group('$KycLinkWalletCubit × RealUnitRegistrationService × FakeBitboxCredentials', () { + test( + 'submit with a disconnected BitBox surfaces BitboxRequired and never reaches the API', + () async { + credentials.behavior = FakeBitboxBehavior.disconnect; + var posts = 0; + final client = MockClient((_) async { + posts++; + return http.Response(jsonEncode({'status': 'completed'}), 201); + }); + final cubit = buildCubit(client); + addTearDown(cubit.close); + + await cubit.submit(_userData); + + expect(cubit.state, isA()); + expect( + posts, + 0, + reason: 'the EIP-712 sign throws BitboxNotConnectedException before the POST', + ); + // The wallet is unlocked for the ceremony and re-locked in the finally + // even though signing throws. + verify(() => walletService.ensureCurrentWalletUnlocked()).called(1); + verify(() => walletService.lockCurrentWallet()).called(1); + }, + ); + + test( + 'retrySubmit after the BitBox connects signs and POSTs to /register/wallet → Success', + () async { + credentials.behavior = FakeBitboxBehavior.success; + Uri? sentUri; + Map? body; + final client = MockClient((request) async { + sentUri = request.url; + body = jsonDecode(request.body) as Map; + return http.Response(jsonEncode({'status': 'completed'}), 201); + }); + final cubit = buildCubit(client); + addTearDown(cubit.close); + + await cubit.retrySubmit(_userData); + + expect(cubit.state, isA()); + expect(sentUri!.path, '/v1/realunit/register/wallet'); + expect(body!['walletAddress'], credentials.address.hexEip55); + // 65-byte ECDSA signature → 0x + 130 hex chars. + expect((body!['signature'] as String).length, 132); + expect((body!['registrationDate'] as String).length, 10); + }, + ); + + test( + 'end-to-end reconnect: disconnected submit → BitboxRequired → connect → retry → Success', + () async { + Uri? sentUri; + final client = MockClient((request) async { + sentUri = request.url; + return http.Response(jsonEncode({'status': 'completed'}), 201); + }); + final cubit = buildCubit(client); + addTearDown(cubit.close); + + final emitted = []; + final sub = cubit.stream.listen(emitted.add); + addTearDown(sub.cancel); + + // Phase 1 — no BitBox: the add-wallet attempt must park on the connect + // prompt, not fail. + credentials.behavior = FakeBitboxBehavior.disconnect; + await cubit.submit(_userData); + expect(cubit.state, isA()); + expect(sentUri, isNull, reason: 'no POST before the device can sign'); + + // Phase 2 — the user connects the BitBox (the pairing ceremony lives in + // connect_bitbox_flow_test); the same credentials instance now signs. + credentials.behavior = FakeBitboxBehavior.success; + await cubit.retrySubmit(_userData); + + expect(cubit.state, isA()); + expect(sentUri!.path, '/v1/realunit/register/wallet'); + // Flush the broadcast-stream queue so the final emit reaches the + // listener before we assert the full transition order. + await pumpEventQueue(); + expect( + emitted.map((s) => s.runtimeType).toList(), + [ + KycLinkWalletSubmitting, + KycLinkWalletBitboxRequired, + KycLinkWalletSubmitting, + KycLinkWalletSuccess, + ], + ); + // One throwing attempt + one successful attempt — the retry actually + // re-engaged the device rather than replaying a cached result. + expect(credentials.signCallCount, 2); + }, + ); + }); +} diff --git a/test/screens/kyc/steps/link_wallet/kyc_link_wallet_cubit_test.dart b/test/screens/kyc/steps/link_wallet/kyc_link_wallet_cubit_test.dart index e67cf499..5e1d1f50 100644 --- a/test/screens/kyc/steps/link_wallet/kyc_link_wallet_cubit_test.dart +++ b/test/screens/kyc/steps/link_wallet/kyc_link_wallet_cubit_test.dart @@ -61,45 +61,74 @@ void main() { blocTest( 'registerWallet succeeds → Submitting → Success', setUp: () { - when(() => registrationService.registerWallet(any())) - .thenAnswer((_) async => RegistrationStatus.completed); + when( + () => registrationService.registerWallet(any()), + ).thenAnswer((_) async => RegistrationStatus.completed); }, build: build, act: (c) => c.submit(_userData), - expect: () => [ - const KycLinkWalletSubmitting(_userData), - const KycLinkWalletSuccess(), - ], + expect: () => [const KycLinkWalletSubmitting(_userData), const KycLinkWalletSuccess()], verify: (_) { verify(() => registrationService.registerWallet(_userData)).called(1); }, ); blocTest( - 'registerWallet throws BitboxNotConnectedException → Failure', + 'registerWallet throws BitboxNotConnectedException → BitboxRequired', setUp: () { - when(() => registrationService.registerWallet(any())) - .thenThrow(const BitboxNotConnectedException()); + when( + () => registrationService.registerWallet(any()), + ).thenThrow(const BitboxNotConnectedException()); }, build: build, act: (c) => c.submit(_userData), expect: () => [ const KycLinkWalletSubmitting(_userData), - isA(), + const KycLinkWalletBitboxRequired(_userData), ], ); blocTest( 'registerWallet throws generic exception → Failure', setUp: () { - when(() => registrationService.registerWallet(any())) - .thenAnswer((_) async => throw Exception('network down')); + when( + () => registrationService.registerWallet(any()), + ).thenAnswer((_) async => throw Exception('network down')); }, build: build, act: (c) => c.submit(_userData), + expect: () => [const KycLinkWalletSubmitting(_userData), isA()], + ); + }); + + group('retrySubmit', () { + blocTest( + 're-runs registration after the BitBox was connected → Submitting → Success', + setUp: () { + when( + () => registrationService.registerWallet(any()), + ).thenAnswer((_) async => RegistrationStatus.completed); + }, + build: build, + act: (c) => c.retrySubmit(_userData), + expect: () => [const KycLinkWalletSubmitting(_userData), const KycLinkWalletSuccess()], + verify: (_) { + verify(() => registrationService.registerWallet(_userData)).called(1); + }, + ); + + blocTest( + 'still emits BitboxRequired on retry when the BitBox is still disconnected', + setUp: () { + when( + () => registrationService.registerWallet(any()), + ).thenThrow(const BitboxNotConnectedException()); + }, + build: build, + act: (c) => c.retrySubmit(_userData), expect: () => [ const KycLinkWalletSubmitting(_userData), - isA(), + const KycLinkWalletBitboxRequired(_userData), ], ); }); diff --git a/test/screens/kyc/steps/link_wallet/kyc_link_wallet_page_test.dart b/test/screens/kyc/steps/link_wallet/kyc_link_wallet_page_test.dart index 703ce9b3..543f0a8e 100644 --- a/test/screens/kyc/steps/link_wallet/kyc_link_wallet_page_test.dart +++ b/test/screens/kyc/steps/link_wallet/kyc_link_wallet_page_test.dart @@ -1,23 +1,32 @@ +import 'package:bitbox_flutter/bitbox_flutter.dart' as sdk; import 'package:bloc_test/bloc_test.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/packages/hardware_wallet/bitbox.dart'; import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart'; import 'package:realunit_wallet/packages/service/dfx/models/registration/kyc/kyc_personal_data.dart'; import 'package:realunit_wallet/packages/service/dfx/models/user/dto/real_unit_user_data_dto.dart'; import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_service.dart'; +import 'package:realunit_wallet/packages/service/wallet_service.dart'; import 'package:realunit_wallet/packages/wallet/wallet.dart'; +import 'package:realunit_wallet/screens/hardware_connect_bitbox/connect_bitbox_page.dart'; +import 'package:realunit_wallet/screens/hardware_connect_bitbox/connect_bitbox_view.dart'; +import 'package:realunit_wallet/screens/home/bloc/home_bloc.dart'; import 'package:realunit_wallet/screens/kyc/cubits/kyc/kyc_cubit.dart'; import 'package:realunit_wallet/screens/kyc/steps/link_wallet/cubits/kyc_link_wallet_cubit.dart'; import 'package:realunit_wallet/screens/kyc/steps/link_wallet/kyc_link_wallet_page.dart'; import '../../../../helper/helper.dart'; -class _MockKycLinkWalletCubit extends MockCubit - implements KycLinkWalletCubit {} +class _MockKycLinkWalletCubit extends MockCubit implements KycLinkWalletCubit {} class _MockKycCubit extends MockCubit implements KycCubit {} @@ -25,6 +34,16 @@ class _MockAppStore extends Mock implements AppStore {} class _MockRealUnitRegistrationService extends Mock implements RealUnitRegistrationService {} +class _MockBitboxService extends Mock implements BitboxService {} + +class _MockWalletService extends Mock implements WalletService {} + +class _MockDfxKycService extends Mock implements DfxKycService {} + +class _MockHomeBloc extends MockBloc implements HomeBloc {} + +class _FakeBitboxWallet extends Fake implements BitboxWallet {} + const _kycData = KycPersonalData( accountType: KycAccountType.personal, firstName: 'Ada', @@ -54,9 +73,11 @@ const _debugAddress = '0xfaeefaeefaeefaeefaeefaeefaeefaeefaeeb6a0'; void main() { late _MockKycLinkWalletCubit linkCubit; late _MockKycCubit kycCubit; + late _MockHomeBloc homeBloc; setUpAll(() { registerFallbackValue(_userData); + registerFallbackValue(SyncWalletServicesEvent(_FakeBitboxWallet())); final getIt = GetIt.instance; final appStore = _MockAppStore(); @@ -65,6 +86,17 @@ void main() { getIt.registerSingleton( _MockRealUnitRegistrationService(), ); + + // ConnectBitboxPage (opened by the BitboxRequired listener) builds a real + // ConnectBitboxCubit off these getIt dependencies. An empty device list + // keeps the connect sheet parked in its idle scanning state, so the tests + // can assert the sheet wiring without driving the pairing ceremony. + final bitboxService = _MockBitboxService(); + when(() => bitboxService.getAllUsbDevices()).thenAnswer((_) async => []); + when(() => bitboxService.startScan()).thenAnswer((_) async => false); + getIt.registerSingleton(bitboxService); + getIt.registerSingleton(_MockWalletService()); + getIt.registerSingleton(_MockDfxKycService()); }); tearDownAll(() async => await GetIt.instance.reset()); @@ -72,6 +104,7 @@ void main() { setUp(() { linkCubit = _MockKycLinkWalletCubit(); kycCubit = _MockKycCubit(); + homeBloc = _MockHomeBloc(); when(() => linkCubit.state).thenReturn(const KycLinkWalletReady(_userData)); when(() => kycCubit.state).thenReturn(const KycInitial()); when(() => kycCubit.checkKyc()).thenAnswer((_) async {}); @@ -87,7 +120,9 @@ void main() { ); group('$KycLinkWalletView', () { - testWidgets('renders the userData name and the current wallet address in Ready', (tester) async { + testWidgets('renders the userData name and the current wallet address in Ready', ( + tester, + ) async { when(() => linkCubit.state).thenReturn(const KycLinkWalletReady(_userData)); await tester.pumpApp(buildSubject()); @@ -97,8 +132,9 @@ void main() { // 0x prefix is present and the address renders fully so a future change // that crops it (e.g. to "0xfaee…b6a0") would fail loud. expect( - find.byWidgetPredicate((w) => - w is Text && (w.data?.startsWith('0x') ?? false) && w.data!.length == 42), + find.byWidgetPredicate( + (w) => w is Text && (w.data?.startsWith('0x') ?? false) && w.data!.length == 42, + ), findsOne, ); }); @@ -150,6 +186,111 @@ void main() { }); }); + group('$KycLinkWalletView BitBox connect sheet', () { + // Drives the cubit from Ready to BitboxRequired so the page listener fires. + void emitBitboxRequired() => whenListen( + linkCubit, + Stream.fromIterable([const KycLinkWalletBitboxRequired(_userData)]), + initialState: const KycLinkWalletReady(_userData), + ); + + // Opens, then settles, the connect sheet. Popping the modal route disposes + // the real ConnectBitboxCubit, which cancels its periodic scan timer — so + // every test must close the sheet to avoid a pending-timer failure. + Future pumpUntilSheetOpen(WidgetTester tester) async { + await tester.pumpApp(buildSubject()); + await tester.pump(); // deliver BitboxRequired to the listener + await tester.pump(const Duration(milliseconds: 350)); // sheet open animation + } + + NavigatorState sheetNavigator(WidgetTester tester) => + Navigator.of(tester.element(find.byType(ConnectBitboxPage))); + + testWidgets('BitboxRequired opens the ConnectBitboxPage connect sheet', (tester) async { + emitBitboxRequired(); + + await pumpUntilSheetOpen(tester); + + expect(find.byType(ConnectBitboxPage), findsOneWidget); + + sheetNavigator(tester).pop(); + await tester.pumpAndSettle(); + }); + + testWidgets('a successful connect (sheet returns true) retries registration', (tester) async { + when(() => linkCubit.retrySubmit(any())).thenAnswer((_) async {}); + emitBitboxRequired(); + + await pumpUntilSheetOpen(tester); + expect(find.byType(ConnectBitboxPage), findsOneWidget); + + // ConnectBitboxView.onFinish pops the sheet with `true` once the device + // is linked; reproduce that result here. + sheetNavigator(tester).pop(true); + await tester.pumpAndSettle(); + + verify(() => linkCubit.retrySubmit(_userData)).called(1); + }); + + testWidgets('dismissing the sheet without connecting does not retry', (tester) async { + when(() => linkCubit.retrySubmit(any())).thenAnswer((_) async {}); + emitBitboxRequired(); + + await pumpUntilSheetOpen(tester); + expect(find.byType(ConnectBitboxPage), findsOneWidget); + + // Back button / scrim tap pops with a null result. + sheetNavigator(tester).pop(); + await tester.pumpAndSettle(); + + verifyNever(() => linkCubit.retrySubmit(any())); + }); + + testWidgets('onFinish syncs the wallet, pops true, and retries registration', (tester) async { + when(() => linkCubit.retrySubmit(any())).thenAnswer((_) async {}); + emitBitboxRequired(); + + // `onFinish` calls `context.pop(true)` (go_router), so host the view in a + // GoRouter stack; with the sheet route on top, canPop is true. + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (_, _) => MultiBlocProvider( + providers: [ + BlocProvider.value(value: kycCubit), + BlocProvider.value(value: linkCubit), + BlocProvider.value(value: homeBloc), + ], + child: const KycLinkWalletView(), + ), + ), + ], + ); + addTearDown(router.dispose); + + await tester.pumpWidget( + MaterialApp.router( + routerConfig: router, + localizationsDelegates: [S.delegate, GlobalMaterialLocalizations.delegate], + supportedLocales: S.delegate.supportedLocales, + ), + ); + await tester.pump(); // deliver BitboxRequired to the listener + await tester.pump(const Duration(milliseconds: 350)); // sheet open animation + expect(find.byType(ConnectBitboxView), findsOneWidget); + + // ConnectBitboxPage forwards the page's onFinish straight to the view; + // invoke it exactly as the connect flow does on BitboxFinishSetup. + final view = tester.widget(find.byType(ConnectBitboxView)); + view.onFinish(_FakeBitboxWallet()); + await tester.pumpAndSettle(); + + verify(() => homeBloc.add(any(that: isA()))).called(1); + verify(() => linkCubit.retrySubmit(_userData)).called(1); + }); + }); + group('$KycLinkWalletPage with missing userData', () { testWidgets( 'renders defensive page with a retry button when no userData is supplied', @@ -172,4 +313,20 @@ void main() { }, ); }); + + group('$KycLinkWalletPage with userData', () { + testWidgets('wires its own cubit from getIt and renders the confirm body', (tester) async { + await tester.pumpApp( + BlocProvider.value( + value: kycCubit, + child: const KycLinkWalletPage(userData: _userData), + ), + ); + + // The page builds a real KycLinkWalletCubit (seeded to Ready) via getIt + // and renders the confirm body — exercising the userData branch of build. + expect(find.text(_userData.name), findsOneWidget); + expect(find.byType(CupertinoActivityIndicator), findsNothing); + }); + }); } From 32a3545f843f4e21fa6548bcd72723fb9c820e71 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 9 Jun 2026 23:53:50 +0200 Subject: [PATCH 2/5] fix(settings): label account action "Logout" instead of "Terminate business relationship" (#721) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The account action in **Settings** opens a logout flow but was labelled **"Terminate business relationship"** (DE: *"Geschäftsbeziehung beenden"*), implying account closure rather than signing out. Relabel it to **"Logout" / "Abmelden"**, consistent with the wording already used throughout the confirm sheet (`realunitWalletLogout`, `logout`). ## Why The settings entry triggers `SettingsConfirmLogoutWalletSheet` → `DeleteCurrentWalletEvent`: a local logout that removes the wallet from the device (hence the recovery-phrase-backup acknowledgement the sheet forces before the action). It does **not** terminate the DFX business relationship, so the previous label was misleading and caused tester confusion. Only the user-facing string changes. The i18n key (`settingsDeleteWallet`) and all logic are intentionally untouched to keep the change focused. ## Changes - **App string** — `assets/languages/strings_{de,en}.arb`: `"Geschäftsbeziehung beenden"` → `"Abmelden"`, `"Terminate business relationship"` → `"Logout"`. - **Visual regression** — settings page Goldens regenerated on the dfx01 runner (`settings_page_default.png`, `settings_page_bitbox.png`); an unrelated ~5 B sub-pixel drift on the home baseline from the regen run was reverted to keep the diff scoped. - **Handbook** — `docs/handbook/de/index.html`: synced the three references to the renamed entry (settings list, screenshot alt text, confirm-sheet description). - **E2E** — `.maestro/handbook/24-settings-delete-wallet.yaml`: retargeted the tap selector from `.*Geschäftsbeziehung beenden.*` to `.*Abmelden.*` so the tier-3 handbook flow keeps finding the entry (still gated on the confirm sheet not yet being visible). ## Test plan - [ ] `Analyze & Test` green - [ ] `Visual Regression` green (settings page baselines reflect the new label) - [ ] `Coverage Floor Gate` green - [ ] `Handbook Build Check` green - [ ] Manual: Settings → entry reads "Abmelden"/"Logout" → opens the logout confirm sheet → logout works --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .../handbook/24-settings-delete-wallet.yaml | 10 +++++----- assets/languages/strings_de.arb | 2 +- assets/languages/strings_en.arb | 2 +- docs/handbook/de/index.html | 6 +++--- .../goldens/macos/settings_page_bitbox.png | Bin 36346 -> 33795 bytes .../goldens/macos/settings_page_default.png | Bin 40203 -> 37621 bytes 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.maestro/handbook/24-settings-delete-wallet.yaml b/.maestro/handbook/24-settings-delete-wallet.yaml index 18c695bc..df26d14d 100644 --- a/.maestro/handbook/24-settings-delete-wallet.yaml +++ b/.maestro/handbook/24-settings-delete-wallet.yaml @@ -3,13 +3,13 @@ # this flow's diagnostic capture lands in build/handbook-captures/24-settings-delete-wallet.png. # Continues from 23-settings-contact (no clearState). The contact page is # pushed on top of the Settings menu, so we tap back to the menu, then open -# "Geschäftsbeziehung beenden". That entry does not navigate to a new page — +# "Abmelden". That entry does not navigate to a new page — # it presents the SettingsConfirmLogoutWalletSheet modal, the confirmation -# step for terminating the business relationship and signing out of the -# wallet. This flow documents that sheet: it stops on the modal and does NOT +# step for logging out, which removes the wallet from the device and returns +# to onboarding. This flow documents that sheet: it stops on the modal and does NOT # tick the confirmation checkbox or tap "Abmelden". # -# Both taps (back to the Settings menu, then opening the terminate entry) are +# Both taps (back to the Settings menu, then opening the logout entry) are # gated on their target not yet showing and re-tapped if one was silently # dropped (Maestro/XCUITest tap-loss on Apple Silicon + iOS 26, # mobile-dev-inc/maestro#3137). Each gate stops its loop the instant the @@ -36,7 +36,7 @@ appId: swiss.realunit.app text: '.*Aus REALU Wallet abmelden.*' commands: - tapOn: - text: '.*Geschäftsbeziehung beenden.*' + text: '.*Abmelden.*' optional: true - waitForAnimationToEnd - extendedWaitUntil: diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb index 6a596d22..cc17f2d6 100644 --- a/assets/languages/strings_de.arb +++ b/assets/languages/strings_de.arb @@ -272,7 +272,7 @@ "settingsCurrency": "Währung", "settingsCurrencyLoadFailed": "Währungsliste konnte nicht geladen werden", "settingsCurrencyLoadFailedDescription": "Bitte überprüfen Sie Ihre Internetverbindung und versuchen Sie es erneut.", - "settingsDeleteWallet": "Geschäftsbeziehung beenden", + "settingsDeleteWallet": "Abmelden", "settingsLanguageLoadFailed": "Sprachliste konnte nicht geladen werden", "settingsLanguageLoadFailedDescription": "Bitte überprüfen Sie Ihre Internetverbindung und versuchen Sie es erneut.", "settingsLanguages": "Sprachen", diff --git a/assets/languages/strings_en.arb b/assets/languages/strings_en.arb index 52406e86..d7b275d4 100644 --- a/assets/languages/strings_en.arb +++ b/assets/languages/strings_en.arb @@ -272,7 +272,7 @@ "settingsCurrency": "Currency", "settingsCurrencyLoadFailed": "Failed to load currencies", "settingsCurrencyLoadFailedDescription": "Please check your internet connection and try again.", - "settingsDeleteWallet": "Terminate business relationship", + "settingsDeleteWallet": "Logout", "settingsLanguageLoadFailed": "Failed to load languages", "settingsLanguageLoadFailedDescription": "Please check your internet connection and try again.", "settingsLanguages": "Languages", diff --git a/docs/handbook/de/index.html b/docs/handbook/de/index.html index 85887157..2c190fe3 100644 --- a/docs/handbook/de/index.html +++ b/docs/handbook/de/index.html @@ -1337,7 +1337,7 @@

06Einstellungen

Vollständige Settings-Liste: Sprachen, Währung, Netzwerk, Steuerbericht, Nutzerdaten, Rechtsdokumente, Kontakt, Wallet-Adresse, Wallet-Sicherung, - Geschäftsbeziehung beenden. Aktive Auswahlen (Sprache, Währung, Netzwerk) + Abmelden. Aktive Auswahlen (Sprache, Währung, Netzwerk) werden inline als trailing Text rechts angezeigt. Die Kachel Wallet-Sicherung erscheint nur bei einer Software-Wallet (Re-Display der BIP-39-Recovery-Phrase) — bei einer BitBox ist sie @@ -1503,12 +1503,12 @@

06Einstellungen

Geschäftsbeziehung beenden — Bestätigung
Das Bestätigungs-Sheet „Aus REALU Wallet abmelden", ausgelöst über - den Einstellungs-Eintrag Geschäftsbeziehung beenden. Der User muss + den Einstellungs-Eintrag Abmelden. Der User muss per Checkbox bestätigen, dass er seine Wiederherstellungsphrase gesichert hat — erst dann wird der Abmelden-Button aktiv. Abmelden löscht die Wallet vom Gerät und führt zurück ins Onboarding. diff --git a/test/goldens/screens/settings/goldens/macos/settings_page_bitbox.png b/test/goldens/screens/settings/goldens/macos/settings_page_bitbox.png index 11b41bae88e0d9cc15e15753e6faf42a1657b94b..5063080f1279704e962acc72eb1c098e03f011a4 100644 GIT binary patch delta 17129 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 ZpJ1@JJinqa&_~^4=~oJp1uqOf{SWhaDX0Jd delta 19700 zcmc(`1yq$?zc0EhFbE3)DG>xjx*G)r>5`J}?#>6L1O=p?Gv|N)>hJyS=2`sB!mM}Djt&3pHCYE)q&yslw$nH` zyo9}(iJQ!hxE+VU(J`r$!=KsDf@7+%*M#Vq-M8K)^VIi`!#*C92maje{K(@WFt!u2 zu_*t^es^Zap^Z;^${4a)?IZb$OS**Ug=`m^@4&(=DVNo*n@7tGx}%1zWXZg` zEF*24oDiR*$*&#mk(1J~vq!Gk4QwEJ9FP%zXwr0{8eQu$59<^~r_vQXTxHYf3i6@f z)_p&_Hg=sEuHvIsFCsU|7)dbeE?A(eD&K>FfzzuEwRw{Gs3<27QYtpKh{!iei<_H0 z!zKzN>w9~9u-3LVX1%jR_3l^HW};T?#=}fweD}s4!!i{L@_BlDdVYumJ%9B0af)>J zpFi3w&<~l3B0YbeEuG6ZCvWUEh)j{LR^f3E7YBZ)r?c2JWBT~{-DI-ve@k_CnkhBZ zmNou$VIsraPO5~BQ%|I0qjvW6ba6H>IKq@?cb z*GTp5$qAE+fi4y&f~anRdCltIBv}sE$liF)61@(bgv3PEYInksY(?RMu^f^f)N6AM z3k!*pV-NBxGg~3@Ri6S%;V+Bk;-+$KH>im$w&`}1{THNc{cn0z5!MdnKK|}nZI&)* zUc9VW#J(I^q)C#g3L>VaJYxFSjsldyNbt*?Bg)BHzTV2c|Hc zmrM{~VfbMaMddK2YSy{_oVD2Tm=MOG4Xp%4-{jn zSNULhIgzOctkNikv5NW!Z(0`bRYe>*`{UMS%YLfSJB(r8oT#HlFgKAR_(fGz2()_R zJNEu|aFYs zn`3eAczSkLj0zDf99i#&k31=4{M9%mpC%sJk)QK0Wp{9JFotcwe|}z@CQbHN<&L{& zo!EyX^Xs=g4X30b2&Y&@oju|{oI6m##xlWh=5C1dpNW8z9Z70xYAW~LrWsVvj^!1y zMbl_^zKM&Mx5VhgGAt}CW^X;i+1c5+%Q|nZi+88~R%3bIluCmozln)Sf*|xR(I|CJ z#8a(AAHj;G?bj&^so8zmTT6uP5qUrN8GwQl3`mP}fkrva)X^ z;6syJTFOR+itwb7eJrC~=lUiw|BcK~Yu=poR0BNBSrg6JV|L=A(m#{)_Nf7N1DvM| zy9Ppx20?iRE5^OYV?+za8c=cXGpg`=-c7%PkLwhWYTfxD>F0)rF8Qq(zvKC0gGp_% zK$vmMO#C>QOmZC`MEyEjU4&@~PY$^_bB(*>z8qPH3Qkk_oI;EJP_pn3LiXxm;V)tL zF}#K)pAbn??OrBx>5?(gl?8tfc8XU{(el2pvN9q}@AL3lhnboAQ{LyYPvHAle#G~8 zPpW>a%V=0A<`L89aPe7lllwe;mD=k4?k;(2xRhVWU2>oPpV|+F?k1Brz3_P`B>WY8 z;^TMQ8~h6U9tOD&^ziv(ETOyPY?K7xW8;rZ|K$tFetckxl&?c zPAm_e-iH|#%q4dRKB1iMqv?Za?~%7YF&pdC`2G8Lgd=+(C&6Qd`;I@a^F`^w+j|60 zU|4to-jqojV*``HzJ7Z!slvv>?t6QIip<5vodS+iIJW--e1?aGj9%Z1ie6P~hIhVi ztcM+AVmx!5AmX${fAH+CXaU`7v*)Yck#(y^5?P`%|H( z?Tcj4QpU32jrj#=q3P>~2LtQ&uQWrb7X!)Zhi-cr8MtxaT4ri;*IsrAhJ2b`uI$cL ztC7AuL1ruVB(nPFC)O0YPZYn*_gF~aw(}dH5Pd{Orj>nkS`dCocZFYfKI?`>#(Yy8 z)wK|C*DuyhZ`^|+fPXctGAXPn=9wkTN4;9i-S&(kdMZ~HV!b-GV|~z|)%vEO{(fUv zo5Z((fY-&<_@mnzHFxjbTj)!~#GS(nkvT3o)nd0D{s;;`Mj8RmD_b&Q&trXmAx+D4 z!mQJ86 z6`xe+y1269*Qvj_3is4q4vd8TAmhbJlop+G+;U!>arzb#(qVOpf#Xj~wf~7SA5X(% z_QGY><%A^3b>lVc>({R;<@T>bGhXh2^0wPw8=nV-g@>zx-@SYHuC={&u-HIivfNDc z`o<+$bwghge|N@^5sc|_h;TSt@c}$QpjD%~Rapy?GN|Y>MOihVp7A6GFAExJ99vFS z-fO@nI@NEt2ldy_!|g=J$jU!D8vlvFsh8mOMYxnFQNZ`_sWnbcu1DKa-WXsPiI=1r zg|5n@jfZAwY^=WB$%xJ`i9qYb~9T6p19@is3Nm5=~1h3SYyEMla1QGzP|B^2^}+| z2aT=w$k6ce660unqJ-M0FQEzQen#cabi)g5p=CJR+uLhLuI>UcF=o81ED+1E#m&sm zd9+PUO}8;I!z1+Pdn;zU?5@?T?E~a?f?BnicGr7i(ZgSio3fgkDu$?ZTQ+2g1tS*6 znAK!W0@miwF$}ZgCXt39;`;*c)zyi?d%rQhRuQ^2RbFC{BZz~81M4|ol(@LKfbE(N z`CWebLU(F;!w-qkUIz(kmIQN(*1i)L53CN>^1O=G;B_kAJCQw3wY2TwWo1op1=mVJZYMm1yk3Ef&lG1do zivoYk_1 z`&L##RW>g#Zd?5zXaY+g)GEwKTIIenxJU@GZMdyS1ZN0sz`kd3G+4lkduY8Y%y+@{~yc}b`HNYB1`zf>W*kbBNr5$w+JtHmceXvc~+OmM_+rY30nBHE# z-M{Y}MX#3KOA$Fkn`3k==0^$E+J2+!XH`|XhIbw>Wj^e8{fZ_O(BNHn+G1Rrli$dLW)>iSa|2`%q1r$7pKi+_&k>B%42bH z(HXf_L3p_TOK9uwIlBoo=o5#~XYXV)o0UwQEi+T5qoXSa@%QCIRHx#@@%pJz=+6~A}-jy2pc~|J>fVmK# zn7o2QfB=48eZE2kPE=Iewf#3RXnN7%6GR@j>{z!BCBvJcfl9Rx)%jpqvpDZ+3kl&A znXNK_2X7vdnZr)YQ7+!@!WqYZ+gR_V-kp(_X0vc{2cYU@ws-s=z1?s$r-$?vA8LUY zmM~#APDb!&Ov|GeFr7c+HhyGKS=`eRNv< z4<_fwZ0W7^^t?Ten^+haf+Hd#{L6PHCIwul;NQPLIB1APRN1ZWUXI{{0)Fbgt!_G& zC6y`?)W2?zUJJ|ncwo}O8jo6$Rb5${sXXN5;3&PwR*34#?BobCkHS?s^Ro>bZEK` zZVA+DVQ@v;I>y%U7t~K}rKiPNo;u6>dt>c0`WXY(>&xO}X#O!duygj*gC8 zJUrRgwtbE}{6jNjm0Hl{gfUxKT-@ii>hW=X63Qo!9}k-ZretEy8YQ{-t+S>N<*9MP z`V#pcoT%TpedkVAx33Rzm!hY%!f4Yi%#q}{dt>=oT7?PH%fxw_#%(c0da-MNZ+V3v z-doPrX4UVgT8b?{vBgRfb6NlDpSol^+8FWPOm-n$U#}8?{8?aRH=W7E8H87K3(FZGPaZG(ehPZp@H3$}t0zr|G4)DGI?t#*g@ z;W0c0_lYhqtliEa6C5&}g}6a=RvO?P+HB~;GrxyL~+udr1 zb@78$xY68f-A|I62v@#%qdE+n=YPOM0w@x5Ibq}uUA5uI#Ktb{P22hS`tFU?x)2L` zCJcp!n!+XIY`_*`RG@ikHSyKS$*R`PS)r0s~8h@TA!iN=P8`LP`9Ex0#!kB*jmVp+MkxC))uAM-eDV>O~HbCvEZ zu&pNp8n#hPG3zW$a*OQQGr#L7dVNFvY$={4cjFTe=arQzryX!}=*`{6#^$2h6fZ*t zo-P^7f)<7#2zth#(r60Jjg*>CgiYA3WE!24@H)tg_5i@Kbhtj`&ga6Cl#~>=Z((7f zT4eYSE_r@&@CZp}?nse{m>Nukv3RYHDfmmh|BO{}$E2Nd@+#xTl0M{+C$s6-8 z(1em^YQ)6Fb=x!GpcLC&OC!o~Nly)w1mQK|=^iXWV3GfYR#w{49J=0|5b)m-%Q-!; z9q~A96xyR$;@HhZ%q8i%qZy61Cd*VJihkLqbyameO8NaeEseP|<52hu^|GoM?vvdW zyUJ+BD+%`xavvo>{>m+52Mf)G)com(CT%8dLqosRRK28wr=O&aib35vYnrQe_^!rj zwIJWL41H-C_#r|eCMFias3kDR_+!Ah)%ucWSwo#PCtshFoDd5`T^;;8;p<0n@n(IN zg|)TQ@vSSDtyN-?)KDLv+nZZkBk98A;@=VP$&TLVvkBio(@JL+^h97z6^VAGk)8Iy zirgaGq54s5(37>IQvPRy!snq(_A~m=^A(T@g(6T&Mu0|fPGFu#cUo*FMcnnQp8Hoy zBHo}`?2zw~>?V8_~hvA>2j{h+M2K@8?R{{+EJ1ZB7 z9aJ$%7hBlRv(JB(p=Y8aB1KKK+5I^}L=imcbjEvc@|RZ}T3Zc&W)C?3u)U5u>55{` z5S9`ZILsEez>g2hjRGU%7V|nV<*9akN0^EHsqXcMdcmj4zfiox_DkoQ+Yu!Ukq@4e z`_SOkIButuDVy0tJQ`V}spH<{$8&5LSB`<>(9r!d^$)yn%CSuT4B;4j&$AS|yZ2jV zuba`mVa<(?k58r0SO?(xB1eJvr_M8s+5O3|hwy~pldik+ERt>V71SdDe%&3%Z)_VM zAIuMYm~uNU!wB|}l(cVrK<}fN*z2-tm7W-8JrNtc`dwLwI9`TYV1NHLAr-@Ayq{Cz zzVB!0g0X{cgrMElB>To_PKI=Y%K??3`!zr2*RReSS>jyWy!^H^f#2u+u+cy;gOH&)1WAPkyeE=>P__+_u4;uN2_Rw{r9k5mh&v=VQz}?++_x|t&Xvn#c|c1~A7rD~ zVY}Pdr2AU1L+Lut{bE71tr~~?eW&Y5-3ft!?(K;ENa3P8E&Hj;`-w)^zR(1;Fz3E{ zj#9ZYSYG{(5TpizaeENa!CpissJi@)v5?C>Z0zOvFW%}Bl}dEWt19PHR>XWNwF?wJ zG%ggPLHgr`Iw{%) z0L(U-u4INm*GHUSQ^tb{dS2f`j^($swfWxp{_N)NdAii*bW!+Fh73UXLSR_eXX|(^ zCd*xnFTrltmmQg7?dWLORhmVoQ5=z(NdwdLK;gk=iBP1P7NnZN4;P`9Oy*QMSx~&w zp5wflAlIO4(*E=mdaS?_m|NQ#f%SA=u5a&TYo}XB`VJmrC|mK385E9X}v);D|Vwjb$zDwk(pd=aIH0tDF&-(>+i!Kbh>aw{v3XuZ>#q zZO~*Q*yb?GJJv|dN!(uQ42RjT^*`d|^mVA5c-vzv9D{Dzl}9*BxPSE(PQMe41?K0@6_PqRIRP}TQu)@+ z(c`E~5q2cWCwhs24f_^6di(_~QVTrwx5c@W-wh}#;r^tyCMnl9F@f;Y<9MrkSXl1! z7Y$aEzcHX#Z;z%ipv78!l#()7^ufK0jeSRZ{?`}D!@bw#Usz9Q%jLurNxp9fFMnPp z+nNvq;4xLCH_n~Mj7oow!g{Tr{OEFnJ^R)}BBE4PhO=pISVBVS3HSKqgn&Fhtp0pO zEhQzzc&3`Y=6X`N@#?Ke2;pTT%7KRjP=niz9gxvWPODk>C5H#(R~1V>LLWcgjj*PX zO=x0Ts>;|;q#pXcI#=`6(}2MHnKHYvHJOf-LN;|LmxCG z(G}Hue+G~X`8r-CQ|(4lmsNam@3YdiXN!<7Bez2B0a?1uMOJ zeVg1c`|UwOFJJ-|o3G15nq)i?fHULLb@cRL0XUT|9UoXy!V18ULCYC7Y{xM0x1)7blc=B5t3}J303u@Nwq4gEUja56R+F3I(_~f`M z-nJF{(&l^ClA3rUAZc;DMp;cX1KLnjR%XzY;%mP#d=D!2FTV*0xEHtG>MT8@+ppZJ zSeIF3mC*}}_;L&5seJm^uU`Z8GdPjg5!PR#%n0PU@0tZh>tSj7zl{nv#uQx?H4mUF z+vU@XZbn$l$#OJMmta&Lr{qAI1Au%HiImV?Ociu}e^>4d4S|;w&VW(Cr{U0F{paQk zG?Ng01Dm$+m(fDJ9jWjCKwnIp&_%7JwDgL!`2D`+Zar}Ov6hyWnv{B>QdCa@?gfmm znFE~#+T8bfnAQOd2v^(BXanN5aJrbz65RCy@zngIQSgFPlCG=2sfy|!EiyczixZN6 zM`iL*CT8OaT7CLeT%_i2PT0FjZ2lU{_N2KI>I5`TPZTZg&i8Zl{8z^Mc7{E}eKaEqM5S z?R1(z`NwanL*qK5zu>Dra%3NK!!xHxRZ1ypdXAR;n_;m?E>20{32{3{x|}oGQm`K+ zAB;GaEqCX$o5)Y(SLRUqJY2jH$WDOMdeuEVgU=NnlOml1e5+wc#dWz1zKX#gbo{oy zAN&_TcC%$29OmpammZH~8XGC9D?SnWM%@QgdgHB;=ZVtF*+(={+LiXKL4=To_LLxo zLtLr@Ayw@zn-xbM6k&GdGey`li{Ek+z%T30LL%po-X6pYaQ|f zTbNFzwD40)@*mMxzCgi0hc~k)Gwq|gh+Qjel9Jy#lC@Oun-;m9mXv01z}|}c1t4ze z?P=n@+u&DzlC}jzIUp#_>ze2CY@P1?MgM!HH$DyeTya79>eZuvxFl%2xG1o`P6z|) z46EZI0njn`HPS2p0T|_;oeo-s0^4S?(NJdU;5-L=U9p1CpnD1aF27s}%X}%Y=6kT$ z1I~|}wCYmQ9fQpYQ4?SGUQjp7>>>+tK8jbr12k%69sF@;g#<8hNq_64LLw3LYDykf zkoF6r(Si)^F_ucZ6Ntf0e>542hn+p!ad-E(ap`#4QfgEQR zlxON}LGf%1{VdcnN&3^{MU<43{dI09x&^5ZQ|9^NU3FOq@9{npad+p_=?EUkvNNKi zrUvsuW@l{;{$psEepda{0(9!ph)?g(XGsM~EJY( z!w4qLXf!8?2N5NB{b?@b+qe0HD!cWmnviP7XY}fs+Yh|FF>&@+zHy?j zL^?a`F5tBNWX_1WQTNO=G-p<*E-Tm zNbuTtMXgHr&Vzcjw?J$_TVv<9ZpB_sY^pC4VvP+TkKEbTg`*f$#euPbr%`eS@H8tS zv-@r2SROS6>n&Vd+?Ow3#sF=Zofs`DvAD2c;u8Ooist~5@(;V!KAUCn(V!pq?%n%A z${iTwNtCs$`5iz7n{!7N5b}WZbVBhiYMi!o!P)_f{x8t14MnJkE;(2>>Ox~AW%?R# zk&6$mG3$xb6N_Jj60~InWKhG%CwfJ>J?{6yt%6g-p8)M?#rltq=}30lK&t4C-9z(i z20`oo7a%(n;w`Ks>U4g*6$@^x2@`i8C~!|JN%0xf8xaUxuSBTEX#tFguI|g38poEg zvFHV>AHQlUI5{{tP9+bM!DIBqU8;WRnk}=#ht-`QHUa;Hl;8ES3vCz(AHUY%iY9CQcb z#l**#Kvp}N04^2U?>(-qt^LO1!IL9U=XUS~@g4@i!f?))m>6^s%#c|k`)*Lm9W%-KfrB*Ip{x{ z=#_HXo+dw?e|I%~K@Elt_F`Q`K6%52hvhL0;Qz0<7$qan8*+c4VXB;QH^1tY<;{tb zgu8;tSKaICN>6>~I4y4eQdpISmG1l;abKxu3hC*|Ksl4p6FfZMPW=>}R1sLNMomxY#g@GXSrZix{$v0Vw$;Gw;W2rJTUTnVF97!;S^L-fZaHSZU5g@4Fl4 zH-Vb+6L|@;E}u({o2Z8bbq1P0fb3B`e~r^nrH!5;B2n1)J|2y7KCxiIsW0%nAA;!# zY>>NvD)q$j;)6As|7yA}Yp+DL`yUx;867+<-iU$x(l9GL)ho3Q8XrmY)uqYocHZr)GpzKe^y+WW}L`o-ys$T!ML-3N2!igxgO zUS(xR!Y7f?!|M;+$>6IuUlHGbYQ*%Li0oR7=rv*c&7QQ8O0g%feP=kBWrl>0y1OkE#kGQJ{bGTIIG^<~o~M=dLg zyLj$uR@r_iabIQ7u#Jq3it@XD2Epntw|UC~FrazcX@j%F?Op)aPIps7GC!?vldOn_ z5GNbtDA5F_5az2r1Z%a~ss#h*_MN@Hnw`v}z6qS-hQ`s+Q2LDev=7k{8gBAnG>oiZnf!@v`-`Y^IU(Uv8OFZ`UQmWGC`i0O|yr!okTL zG*n@wg@Hxj1&5RAb^K_{ks{L7Wq3$*uv;H=UUqC6R8zz2T9*{O#oK?(u!+Rq%2ViMhMki}Hg$dBP1Wdz^c$@Tz+nZR`(><1xd)b_*) z#U?IRYmCW~KN<8VyI_LLB(Q*nS7^zv zkpF}K+5rChH|uu$DFh#pbZaSjETAd#SN3=TpC)w7lGOrn=PToET~SuCHy#wS`^RY_ zB`h?w3G5G~Re!k|0mzM!qyi@0$W^dgT1JK*6M?n8J>5RX5h#&Bz8H}c2oaZi`uNFX z8Z(Qa#Kc4@tRN8zKjauQD`!Vr+be_tkT5?=NTlkv1wyzMq~c;?dZAuwhDwSjoq)Wc?J5E*q;C`a%95+C}R5S$) ziF#pw9A?AeHF?}Sf#SNc~9bpEuD_NNx z*JV^GB+0%e0*@oh@8*66zb_E{3*LXdF)w0DrG=rX|3PKD==B)-L$J2 zZXE4Osm!4Q%wGVc8I`TlhCCE+?{%*Ge$Mv`XP?r7p7|GF2U#=4N^as#hg4M&YFbP_ zH75KZ$HdCoeROcym&hwRyiNns*}Kl$-1a;(6eD|pc(KxJaV5Qh!Ax5?5q{}7HjFn8_bqWTmT>{r5X#kz`^YOBj3xLpHn<<;zOcAZ2LP$thYC7=Pt3OqA zKM`8QB(qk6qJkQ%bhi_@!L1{9;;;LO4;2&?AeXHX<$$07#SB!wsuU;`q*=k;(e!$q zi?4nV2?jR_`|-&W`ZDj&oB(0gAabVwl(t<23J}DXbaX$ol=6S+wD`4s?nsdxM_h^R z0qzC%`)l|FB4zEV8Ci>rXg;4U4Qw;0zG){kpjKrrt-t$ukVv|8Y!{II?dnb-fO^7X zxdQ+S5&))>8bFRfRuh$by1BJk1Wbczb>460z&{7!yv6P9zCQ!0m5J{0z@W8W=^=dP zS>a_eB$y29NLo2IDr-ItuLrmK@nfNgVBH6PAl*`d`PWv;sjN(Y*A5SboW^c0F!UAq zEACiYu>L2z&>46i5ZeJl&^oKXNgDuq{Yf?eH-e-y{$Aoz^#f$r70oCEXxEn;H|$Tg zuVU0xYSTUT1I`XAPdZpZ*q~~K+m;f!Spq;a-~gW!>)pl}goK1D?N+7ljT{fS^Vm_r zCE>xoZBS~J?6>Pca8ndE6_v43v1Vsey|k=Ayn%kW&IXZtW-xQI>KR;DkE_J#=~%Yc zUgEPKz*(mkK$!W{YvuArBn}vT5R!$_X%@%09wBpfMzR#B5$REA!h0Yim^_9=k5+g0 z_s~?4EQ=hVvE*J_(2EKQ-^0OapO}a}J3=0u?dtHIOxyjODJ^lIU;+C>03RL{VD>>7 z4Qq!@hS-oi#I`X&82&YeG`YGsu-~4J?bG1NG3o|wg6P@PB1q4VN zj)=I3T~+e54ptBc5I|JB!wNE4M-oyJe*v8bY+o>zFc#PwUzq>4o5mG^fF^3spN#9F zy%z|CC%LQ;`ZX}$WMpK7p?NQ_n{*N!A=7a2BPa+P-W&H}DeI%5C&zbC$?-C$-P*K* zRzXs9Wi6Bfi1%o`3Q`n$Mn=&`{9NuaRx1$H@_@0n5jbNb%OIjQvCgH+ydp& z#)ykD6aw~NeU9fc$kd^ueC@d{)9T-oM`rGIyZF z_FTIP0Bz=1AJF?;4IvI9;jk}Yjmg)$yYz{Q>aiIP*2wI*H<*FMECO|~3X?`->EOpFq&J zZ#18(xVy2zm%)|-4i7B0Kq{f1J53^^Z^NCt8=U-u0vxhhg;LRHIknjBF6l||fS_2B zR5|obpl@bI@X@138>?3@PCSnv;M2*16m=wcFd7AF89DTmP@zs~#L4TwhZMs}OQpbN z8m`WA&_lF8=oKA-{w6rtrGVh}>Omn`*vduhuT=S#KDQbV)^l*WonDwg?akN;)wW0X zVbapU8>J%+XOEvg{atSfj4vs^cx!qAQg3pk`0_tA6yME0Ew8mTJ9IMvg546WMuxjn zp>u?X$T>1VPs(c1$+0SS)8Rjp40Z>6{wNo>6WMr!+S<b@TK>ivfg1Nk0qzz02O!yH1@+4KAGo0l9W^tuV* zlNku|o5vqsU)*{-#bGhLqXg8SksbBm6wLx>TRb(U%E`~m+%qM`{7B7w|43i%tXQg^ z-bTO_%rHh%Lv~)537=`oOe|}RdJkvHd5_1a=t8=>#uM3iumT*rIKzP9F)IYw(Ng#t>aOK z99DwL5foftCqZq;TaAnXuFl@@D`W$SI*4U;!vVrB>MW-Ky`2&AS`uGk9*#jhtD4;! z2i4Lu)u0SLe)8l-rR%F46Dn;j7!aOp^Qh_Rv5AO?{%r54f}{zE|4Jru93>yUk*xAE z{AMQIAoT9H5#MQ2oPVSV$eB5Xke*tMH%4?S0OvH3XCu!pn&Hl8r!v!#VR()a{B3KS z0rnuxnp08ci9&N(?$Kx#q^w+$2Lz5mHtNXXRSIz}ke(@#nONbjbF(nYrRx#vWMXEk zIdB>As#b|+&ak7;;PXxvTW9{u{gLQ=5y+F)iSqKYaA)9q5b&3GElvj!30n)#|m*kDN|&=j#aK+54LT z<$tT^#Knv!Q6JWub4vlly>dqush+zmtbkBsK2 z=~-I#E&Ti`6se~d?f?_3T*dk%_Z0AcnDa`kQH+2d&rC6{;c8#f2pzOJ&TI+I_H67n z*e>4(QaOw-ai%un$xD#)Dl91(+AK*3Hvb@3%wvL$Zub-!N|!pwb9&cB|05x%sJ|t7 z!)bnFBnRwvboQ#;z={K{8cZJT_Ci9qtUEa_hnpi0B)Y%y!}evZnKGS(F>hv46yHD7 zpxe0oROdSCbn=h(P{{AQ){h{?0)5-3N3Lz*Ipg{04p8O=-;CIDQMY44ctYRBtx49< z!Z)2h{PQAQ@KHq?b2EL=e*=02qI@4bLpLY_B1K$V0M=;>fm=iEh_&fOfFVLgYr0`H(;)#JlwR~}xcO`753u^i20C9rJ5H;1Wnb{7Ezzu9*;_|m>yBd*y19zh$SgJ9E=< zYZ~Mm-org!9e3pvWbfcFfq4v_XZ`Q}t%z++G$vW19p#1lv%3TeN^7mI-SW#{$&fm- zt-Z)aQqc2)O$VKF-f7T!47jM8|*!Ji!LF|l^=pjfP1E%^4Kshbt zycCHs2DG+^-m@AS8hEt2qv+Qsa-v<^#T_T?8cq}7Q1tb0Qer-S{@A0584fELzY0Q z_Q<-9xD(0R+}7DCcKNE#0r{-La<)0`>jNbv+?`<=kW23B+DPub9hmYn=I2{gGqcT2 zTfy?ba(E6^AY0W6J`5Ga^`eKaI(uY{20;+=$gc!+`U0xmatO%?Gz|@fw@E}VsRa)h zQ!+D$f)dDQUqgDV(IYo}-uZ8Rzdv^(Wo$gQ7dvG)p^&DL(Xc#WBIezSj||fv>HAr* z)h$%{3pGQ1ZcZ36-SIH|DV^aqKO_d;wFKzL-luPwoIESSd8-e%;NKR$vYpJ?F|e}I zn&*`{c-{rM3WCrRZKc4ZqJ~(M+EW=!Y+S_i{E0#x;W9pu<}72RE_h_T-a1^|6z`!eHi|&@) z)1RoYA`>8!^w~@&QBzZUNkIV|0WgtAEmR6zpwe#-uXDud2yAH@=T^gP`5np>%WO+? zUn1P`Wy(QoR?-Layp2F8xLnb0?kLi+)?{!ARQ9;{=aLBhXf${^Z(pv{GUi` z(0|^V{GS~0j{~j>Jlt@Li|co|u52a@CYB8!2HyRq9`%R4nCI>LFd9QP9=UKa({zP^ z<1mvjY2Be%X&wdEa1*M;a)onIp69W{(l#ve;bK9MqRF5iR-1`tK|`M%H)3ak)voWHuVIz$EjbqKX*29!@)z_!pQf=OXvPh9 zX?*Xmkw=0DrtAJ`HoK7rF_)Up7rTvZ1Ki~%f8|JrAd&s7n)(QKWJSV2(bd^3)HR8% zhFD{kF+$WQT_Bytl8$WV%^zX(WjSdIyvc!2sE=B>?L`uMr18O<&$fN&=xC zm^g;VQ`D=tQT2lKy}c8~TWNenhI)RuW{49dr)590XG<%{XUltvX8Uy+HN_3*+9JUO zaWa>En09-oMOCY04YA0ABzP5TI;EJBUkd8OOxYa2`s;;@C44MhQ6} z0`2?O|5P+wx7d^MCAgGiELRAg&SDWwI(E%j*A_6I6sW`=5mlsld^B6>U8k9Ha|UL_;=h zjoc%XlVKCl(tQeMoUg>r&WTiObj+S1;PI3bsBnH%mv_i2`jJ2uwl~M^1Xq&(h?F}CC030~+Et`Sy~9k* zJsE$loZA!jArVy~GZ%Friq#8g%^qk=C#;c;pDbAM$KuYMZcjU`TDa^jYp6~+oS9^H zG?Y-q!{_d7*BdfA`kv^dicx#IPlb?Rk*-#Ff1C;fc|9PdG#ttPx+a+RU8F z;?d<(T87YGSAcz{k)o-q3CLEoOdekx*PKmf&OIxaqcoQ<_c3ibpwaY28 z+-`<%xcEWOwj+;oX+D`^qRQ-~QQ*O9Z_kBGv>`aQYClkQqH@`HDCxS|sA44>Rbn@h zYZA+=XPRG?ZKti43Wb`Fm-3?raCd%1ox=>U==c(gjyHt-ruyw6M2RCv8R@i3?Mu5@ z=YbgY-SSYQwMgG<5(TjYJ3Axk&VZ{c^O-B#q|Y_46YY2g`HW@5mLfH3O3Y5`FHmy= zOofn}8?y25Ld#?~Cge8t(|tv+A$D2oT8R{OJXB@V`4y_YuKR8~>*CPUi`e-S@x23^ zt^%7d0US_bH0M-}U>uCa1KTYe?c1z7#)Zi2i;noXl*{r|4G5iPH(C80qO* zgMsW&=Z;REx7y&8+rG-qXC}@$kLovL4Mv_x>J`iqn)rBJ%3uxWyY&A;PwUD2y!FXi zet0Exx$?!L#lPIBxpJTi*_UQ@I<3W)`gif74SwaxVLq9}D7gEvUESJq)&-SYjJT63 zqJMC}ixCxaa2CJLnlIUEM$L&)Q*CU+U<_o<5i7n(zmScx+h3Xt5(|qZiN?x>bGm)j zoJE;db6Clw4t$&ocrsDuHb+Fdx*IxH>~!5}wZ0R>&928l5POT?UwXo7^sp1P2W|C1 z<}TzN=V$xl4c9NEq@-ebOfKRDPGU*Ii_cZkFYTJzlez{o8#?Ee_ zv-)Fmc8b67@`;31XY|zq5h|Q^7TDX9#ygR)t@btG?S@|H!$^nq8!V;$Hp>yR5>LIf}xpMT+B(7*I-|NP0I zf91pc^OyI5{)J=t|KajCpnv%r{paoi`d3e=f9~=^rvKvkjK2J0!;Ke@WR$vFw`pPE OkJtz4_xVEFU;i5vY(#kg diff --git a/test/goldens/screens/settings/goldens/macos/settings_page_default.png b/test/goldens/screens/settings/goldens/macos/settings_page_default.png index 5ea315d3bbebbbc4d54c422d264dd2004e29d344..8a4dacff72f5cb4303bfb7101760e40526b386dc 100644 GIT binary patch delta 19940 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 delta 22550 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 From 5fffb7402f32621f78da29a9dd03210523e33307 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 9 Jun 2026 23:54:27 +0200 Subject: [PATCH 3/5] feat(pin): show a loading indicator while verifying the PIN (#726) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary After entering the 6-digit PIN, the app showed **no loading feedback** while it (a) ran the off-thread iterated PIN hash and (b) decrypted + loaded the wallet before navigating to the home screen. On Android this gap is noticeable and looks like a frozen screen. This PR adds a spinner with a **"Signing in…" / "Anmeldung…"** label that covers both phases. ## Why Reported in tester feedback: after PIN entry on Android, it takes a relatively long time until the next screen appears, with no spinner or anything indicating the app is working in the background. ## How - New `VerifyPinVerifying` state, emitted at the **start** of `checkPin()` (before the `verifyPin` hash) carrying the entered PIN. - The view treats `VerifyPinVerifying || VerifyPinSuccess` as "loading": the number pad is replaced by a centered `CupertinoActivityIndicator` + label, and the PIN dots stay filled so the screen doesn't look reset. `VerifyPinSuccess` keeps the spinner up through the post-success wallet load while this screen is still on top (navigation only happens once `isLoadingWallet` clears). ## Changes - `verify_pin_state.dart`: add `VerifyPinVerifying`. - `verify_pin_cubit.dart`: emit it before the hash check. - `verify_pin_page.dart`: swap number pad → spinner while loading; keep dots filled (block sized to the number pad footprint to avoid layout jump). - i18n: `pinVerifying` = "Anmeldung…" / "Signing in…". - Tests: cubit sequence (Verifying → Success), state equality, two widget tests; new golden `verify_pin_page_verifying` (regenerated on dfx01, `pumpOnce` for the never-settling spinner). ## Test plan - [ ] `Analyze & Test` green (cubit/state at 100% scoped coverage) - [ ] `Visual Regression` green (new verifying baseline) - [ ] `Coverage Floor Gate` green - [ ] Manual (Android): enter PIN → spinner + "Anmeldung…" appears immediately and stays until the dashboard loads --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- assets/languages/strings_de.arb | 1 + assets/languages/strings_en.arb | 1 + .../pin/bloc/verify_pin/verify_pin_cubit.dart | 31 +++++++----- .../pin/bloc/verify_pin/verify_pin_state.dart | 4 ++ lib/screens/pin/verify_pin_page.dart | 45 +++++++++++++++--- .../macos/verify_pin_page_verifying.png | Bin 0 -> 15500 bytes .../screens/pin/verify_pin_golden_test.dart | 20 ++++++++ .../verify_pin/verify_pin_state_test.dart | 22 +++++++++ test/screens/pin/verify_pin_cubit_test.dart | 33 +++++++++++++ test/screens/pin/verify_pin_page_test.dart | 35 +++++++++++++- 10 files changed, 173 insertions(+), 19 deletions(-) create mode 100644 test/goldens/screens/pin/goldens/macos/verify_pin_page_verifying.png diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb index cc17f2d6..8c521eff 100644 --- a/assets/languages/strings_de.arb +++ b/assets/languages/strings_de.arb @@ -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", diff --git a/assets/languages/strings_en.arb b/assets/languages/strings_en.arb index d7b275d4..e72db870 100644 --- a/assets/languages/strings_en.arb +++ b/assets/languages/strings_en.arb @@ -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", 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 8a43a36b..f6efcbb8 100644 --- a/lib/screens/pin/bloc/verify_pin/verify_pin_cubit.dart +++ b/lib/screens/pin/bloc/verify_pin/verify_pin_cubit.dart @@ -33,18 +33,27 @@ class VerifyPinCubit extends Cubit { } Future 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)); } } diff --git a/lib/screens/pin/bloc/verify_pin/verify_pin_state.dart b/lib/screens/pin/bloc/verify_pin/verify_pin_state.dart index b36f1c08..4c4e5d9c 100644 --- a/lib/screens/pin/bloc/verify_pin/verify_pin_state.dart +++ b/lib/screens/pin/bloc/verify_pin/verify_pin_state.dart @@ -18,6 +18,10 @@ class VerifyPinState extends Equatable { List get props => [pin, failedAttempts]; } +class VerifyPinVerifying extends VerifyPinState { + const VerifyPinVerifying({required super.pin, super.failedAttempts}); +} + class VerifyPinSuccess extends VerifyPinState { const VerifyPinSuccess() : super(pin: ''); } diff --git a/lib/screens/pin/verify_pin_page.dart b/lib/screens/pin/verify_pin_page.dart index 9b2d9384..24665562 100644 --- a/lib/screens/pin/verify_pin_page.dart +++ b/lib/screens/pin/verify_pin_page.dart @@ -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'; @@ -98,6 +99,9 @@ class _VerifyPinViewState extends State { }, 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(), @@ -139,7 +143,7 @@ class _VerifyPinViewState extends State { spacing: 16.0, children: [ PinIndicator( - pinLength: state.pin.length, + pinLength: isVerifying ? pinLength : state.pin.length, expectedPinLength: pinLength, wrongPin: state is VerifyPinFailure || isLocked, ), @@ -174,13 +178,16 @@ class _VerifyPinViewState extends State { ), ), const Spacer(), - IgnorePointer( - ignoring: isLocked, - child: NumberPad( - onNumberPressed: context.read().addDigit, - onDeletePressed: context.read().deleteDigit, + if (isVerifying) + const _VerifyingIndicator() + else + IgnorePointer( + ignoring: isLocked, + child: NumberPad( + onNumberPressed: context.read().addDigit, + onDeletePressed: context.read().deleteDigit, + ), ), - ), if (widget.bottom != null) widget.bottom! else const SizedBox(height: 60.0), ], ), @@ -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, + ), + ), + ], + ), + ); +} diff --git a/test/goldens/screens/pin/goldens/macos/verify_pin_page_verifying.png b/test/goldens/screens/pin/goldens/macos/verify_pin_page_verifying.png new file mode 100644 index 0000000000000000000000000000000000000000..3d4bc6d2a86bbd68eb097b2cc5c1e34aa2641c49 GIT binary patch literal 15500 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 cubit.state).thenReturn(const VerifyPinVerifying(pin: '123456')); + when(() => cubit.checkBiometricAvailability()).thenAnswer((_) async {}); + return wrapForGolden( + BlocProvider.value( + value: cubit, + child: VerifyPinView(onAuthenticated: () {}), + ), + ); + }, + ); }); } diff --git a/test/screens/pin/bloc/verify_pin/verify_pin_state_test.dart b/test/screens/pin/bloc/verify_pin/verify_pin_state_test.dart index afa87298..02821d16 100644 --- a/test/screens/pin/bloc/verify_pin/verify_pin_state_test.dart +++ b/test/screens/pin/bloc/verify_pin/verify_pin_state_test.dart @@ -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); diff --git a/test/screens/pin/verify_pin_cubit_test.dart b/test/screens/pin/verify_pin_cubit_test.dart index 3bf86183..5c439991 100644 --- a/test/screens/pin/verify_pin_cubit_test.dart +++ b/test/screens/pin/verify_pin_cubit_test.dart @@ -110,6 +110,24 @@ void main() { verify(() => secureStorage.resetPinLockout()).called(1); }); + blocTest( + '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); @@ -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', () { diff --git a/test/screens/pin/verify_pin_page_test.dart b/test/screens/pin/verify_pin_page_test.dart index 37fc6ad0..38978f97 100644 --- a/test/screens/pin/verify_pin_page_test.dart +++ b/test/screens/pin/verify_pin_page_test.dart @@ -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'; @@ -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'; @@ -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( From 1fea3323e755062c1c2ea476f1df47c4b7abeedb Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 10 Jun 2026 00:25:26 +0200 Subject: [PATCH 4/5] fix(bitbox): explain an unseeded device instead of looping (#724) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Connecting a **brand-new BitBox that has no wallet set up** (no seed) left the user stuck. Pairing succeeds, but the device has no seed to derive an ETH address from, so `getETHAddress` comes back empty. That empty read failed as a generic error → `BitboxNotConnected` → a SnackBar "something went wrong" → and because the re-scan timer is re-armed, the device is immediately found again and the user is walked through the pairing code → fail → SnackBar **loop**, with no hint that the real problem is simply an un-set-up device. (The earlier fix #710 stopped the empty address from being *persisted* — no more grey screen — but did not distinguish "no seed" from a transient empty read.) ## What changed After channel-hash verify, read the device's firmware status via the new `bitbox_flutter` `getDeviceStatus()` (cached read, no device round-trip). When it reports `uninitialized`, emit a dedicated **`BitboxNotInitialized`** state that explains the user must set up / restore a wallet on the device first. - The state offers a **retry** (`recheckDeviceStatus`) that re-reads the status — if the user has since set up a wallet, the connection continues without re-pairing. - It deliberately **does not arm the re-scan timer**, so the silent re-pair loop is gone. - Only `uninitialized` is treated as "no wallet". Other non-ready statuses (e.g. firmware-upgrade-required) intentionally keep the existing failure path rather than being mislabelled. The address-derivation/observe/sign tail of `confirmPairing` was extracted into `_acquireWalletAndConnect()` so the initial flow and the retry share one path. ## Dependency Requires `bitbox_flutter` `getDeviceStatus()` from **[DFXswiss/bitbox_flutter#29](https://github.com/DFXswiss/bitbox_flutter/pull/29)**. This PR temporarily pins the plugin to that fix branch; it will be moved to the **`v0.0.9`** tag once #29 is merged and tagged. **Kept as Draft until then.** ## Test plan - [x] `flutter analyze` — clean (only the pre-existing generated-`i18n.dart` warning) - [x] `flutter test` — bitbox cubit / service / view suites all green - [x] Cubit: unseeded → `BitboxNotInitialized`, no wallet created, no re-scan loop (state stays stable); retry continues once seeded; retry stays while still unseeded; no-op off-state - [x] Service: `getDeviceStatus` pass-through via the simulator - [x] Widget: `BitboxNotInitialized` renders retry + cancel; retry calls `recheckDeviceStatus` - [x] i18n: new keys in both `de`/`en` ARBs (case-sensitive ASCII order), regenerated - [ ] On-device: pair an un-set-up BitBox → lands on the explanatory screen; set up a wallet + retry → continues to the dashboard --- assets/languages/strings_de.arb | 2 + assets/languages/strings_en.arb | 2 + lib/packages/hardware_wallet/bitbox.dart | 7 + .../bloc/connect_bitbox_cubit.dart | 91 +++++++++++-- .../bloc/connect_bitbox_state.dart | 9 ++ .../connect_bitbox_view.dart | 14 ++ pubspec.lock | 4 +- pubspec.yaml | 2 +- .../hardware_wallet/bitbox_service_test.dart | 20 +++ .../bloc/connect_bitbox_cubit_test.dart | 120 ++++++++++++++++++ .../connect_bitbox_view_test.dart | 20 +++ 11 files changed, 279 insertions(+), 12 deletions(-) diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb index 8c521eff..5f9c8675 100644 --- a/assets/languages/strings_de.arb +++ b/assets/languages/strings_de.arb @@ -53,6 +53,8 @@ "connectBitboxContent": "Bitte verbinden Sie Ihre BitBox mit Ihrem Smartphone.", "connectBitboxContentIos": "Bitte verbinden Sie Ihre BitBox mit Ihrem Smartphone und aktivieren Sie zusätzlich Bluetooth.", "connectBitboxFailed": "Es ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.", + "connectBitboxNotInitialized": "Auf dieser BitBox ist noch keine Wallet eingerichtet. Bitte richten Sie über die BitBox-App eine Wallet auf dem Gerät ein oder stellen Sie eine wieder her und versuchen Sie es erneut.", + "connectBitboxNotInitializedTitle": "BitBox noch nicht eingerichtet", "connectBitboxSignInHint": "Nach der Code-Überprüfung wird die BitBox um eine zusätzliche Bestätigung zur Anmeldung gebeten.", "connectBitboxSignatureCapturing": "Bitte bestätigen Sie die Anmeldeanfrage auf Ihrem BitBox-Gerät. Diese Signatur wird einmalig erfasst, damit künftige Käufe Ihre BitBox nicht erneut benötigen.", "connectBitboxSignatureCapturingTitle": "Anmeldung bestätigen", diff --git a/assets/languages/strings_en.arb b/assets/languages/strings_en.arb index e72db870..fdce5a69 100644 --- a/assets/languages/strings_en.arb +++ b/assets/languages/strings_en.arb @@ -53,6 +53,8 @@ "connectBitboxContent": "Please connect your BitBox with your Smartphone.", "connectBitboxContentIos": "Please connect your BitBox with your Smartphone and activate Bluetooth.", "connectBitboxFailed": "Something went wrong. Please try to connect again.", + "connectBitboxNotInitialized": "This BitBox has no wallet set up yet. Set up or restore a wallet on the device using the BitBox app, then try again.", + "connectBitboxNotInitializedTitle": "BitBox not set up yet", "connectBitboxSignInHint": "After verifying the code, the BitBox will ask for one additional confirmation to sign you in.", "connectBitboxSignatureCapturing": "Please confirm the sign-in request on your BitBox device. This signature is captured once so future purchases won't need your BitBox again.", "connectBitboxSignatureCapturingTitle": "Confirm sign-in", diff --git a/lib/packages/hardware_wallet/bitbox.dart b/lib/packages/hardware_wallet/bitbox.dart index 1e5caa6c..0a6e9a6a 100644 --- a/lib/packages/hardware_wallet/bitbox.dart +++ b/lib/packages/hardware_wallet/bitbox.dart @@ -129,6 +129,13 @@ class BitboxService { if (!didVerify) throw Exception('Failed to verify'); } + /// The paired device's firmware status (`uninitialized` / `seeded` / + /// `initialized`). Read after pairing to tell a device with no wallet set up + /// (`uninitialized` — cannot derive an address) apart from a ready device. + /// Delegates to the plugin's cached-status read, so there is no device + /// round-trip and it cannot block. + Future getDeviceStatus() => bitboxManager.getDeviceStatus(); + /// Derives the wallet's ETH address from the device, retrying transient empty /// reads before giving up. /// diff --git a/lib/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit.dart b/lib/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit.dart index 96da7838..0774d112 100644 --- a/lib/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit.dart +++ b/lib/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit.dart @@ -24,6 +24,19 @@ class ConnectBitboxCubit extends Cubit { // walked away and the device-side ephemeral noise channel has died. static const Duration _defaultPairingPinTimeout = Duration(seconds: 120); + // The bitbox02 firmware status for a device that has no wallet set up — it + // cannot derive an address. Mirrors the SDK's `StatusUninitialized`. Only this + // value is treated as "not set up": other non-ready statuses (e.g. a firmware + // upgrade requirement) are intentionally left on the existing failure path + // rather than mislabelled as unseeded. + static const _statusUninitialized = 'uninitialized'; + + // The status read is a local cached lookup (no device round-trip), so it + // returns in milliseconds. This cap only exists so a hypothetical stall can + // never hang the pairing flow — on timeout the read is treated as "not + // uninitialized" (fail-open) and the normal acquire path proceeds. + static const Duration _deviceStatusTimeout = Duration(seconds: 5); + ConnectBitboxCubit( this._service, this._walletService, @@ -155,15 +168,18 @@ class ConnectBitboxCubit extends Cubit { 'Disconnect the device, restart the app, and re-pair.', ), ); - final wallet = await _acquireWalletOrDefault().timeout( - _createWalletTimeout, - onTimeout: () => throw TimeoutException( - 'BitBox did not return an ETH address within ' - '${_createWalletTimeout.inSeconds}s. Try disconnecting and re-pairing.', - ), - ); - _service.startConnectionStatusObserver(); - await _captureAuthSignature(wallet); + // A device paired without a wallet set up has no seed, so the address read + // below would come back empty and fail as a generic error — bouncing the + // user into the silent re-scan loop with no idea why. Detect it up front + // and surface a dedicated state. Returning here (instead of throwing) means + // no re-scan timer is armed, so the device isn't picked up and re-paired in + // an endless loop. + if (await _isDeviceUninitialized()) { + if (isClosed) return; + emit(BitboxNotInitialized(currentState.device)); + return; + } + await _acquireWalletAndConnect(); } catch (e) { developer.log(e.toString(), name: '$ConnectBitboxCubit'); _pendingInit = null; @@ -173,6 +189,63 @@ class ConnectBitboxCubit extends Cubit { } } + /// Reads the device status, treating ONLY a clean, explicit `uninitialized` + /// read as "no wallet set up". Any failure or unexpected value returns false + /// (fail-open), so a status read can never block a device that would otherwise + /// pair successfully — it only ever adds the dedicated unseeded path on top of + /// the existing behaviour, never removes the working one. + Future _isDeviceUninitialized() async { + try { + final status = await _service.getDeviceStatus().timeout(_deviceStatusTimeout); + return status == _statusUninitialized; + } catch (e) { + developer.log( + 'device status read failed/timed out, treating device as ready: $e', + name: '$ConnectBitboxCubit', + ); + return false; + } + } + + /// Acquires the wallet from the device and finishes the connection. Shared by + /// the initial pairing flow and the [recheckDeviceStatus] retry so both run + /// the same create/observe/sign sequence. + Future _acquireWalletAndConnect() async { + final wallet = await _acquireWalletOrDefault().timeout( + _createWalletTimeout, + onTimeout: () => throw TimeoutException( + 'BitBox did not return an ETH address within ' + '${_createWalletTimeout.inSeconds}s. Try disconnecting and re-pairing.', + ), + ); + _service.startConnectionStatusObserver(); + await _captureAuthSignature(wallet); + } + + /// Re-reads the device status after a [BitboxNotInitialized], for when the user + /// has set up / restored a wallet on the device and wants to continue without + /// re-pairing. If the device now reports a wallet, the connection proceeds; if + /// it is still unseeded, the state is re-emitted so the user can try again. + Future recheckDeviceStatus() async { + final currentState = state; + if (currentState is! BitboxNotInitialized) return; + try { + if (await _isDeviceUninitialized()) { + if (isClosed) return; + emit(BitboxNotInitialized(currentState.device)); + return; + } + if (isClosed) return; + emit(BitboxPairing(currentState.device)); + await _acquireWalletAndConnect(); + } catch (e) { + developer.log(e.toString(), name: '$ConnectBitboxCubit'); + if (isClosed) return; + emit(BitboxNotConnected()); + _checkForTimer = Timer.periodic(const Duration(milliseconds: 500), (_) => checkForBitbox()); + } + } + /// Captures and caches the auth signature as an awaited, user-guided step of /// the pairing flow. The BitBox is guaranteed connected here, so every later /// buy / KYC / user-data call can run off the cached signature without diff --git a/lib/screens/hardware_connect_bitbox/bloc/connect_bitbox_state.dart b/lib/screens/hardware_connect_bitbox/bloc/connect_bitbox_state.dart index 8384e478..7e679463 100644 --- a/lib/screens/hardware_connect_bitbox/bloc/connect_bitbox_state.dart +++ b/lib/screens/hardware_connect_bitbox/bloc/connect_bitbox_state.dart @@ -24,6 +24,15 @@ class BitboxPairing extends BitboxFound { BitboxPairing(super.device); } +/// The paired BitBox has no wallet set up yet (firmware status `uninitialized`), +/// so no address can be derived. A dedicated state — not the generic failure — +/// so the UI can tell the user to set up / restore a wallet on the device first, +/// instead of bouncing through the silent re-scan loop. Carries the device so a +/// re-check can continue the connection without re-pairing. +class BitboxNotInitialized extends BitboxFound { + BitboxNotInitialized(super.device); +} + class BitboxCapturingSignature extends BitboxConnectionState { final BitboxWallet wallet; diff --git a/lib/screens/hardware_connect_bitbox/connect_bitbox_view.dart b/lib/screens/hardware_connect_bitbox/connect_bitbox_view.dart index ee72a39f..7f673133 100644 --- a/lib/screens/hardware_connect_bitbox/connect_bitbox_view.dart +++ b/lib/screens/hardware_connect_bitbox/connect_bitbox_view.dart @@ -121,6 +121,20 @@ class ConnectBitboxView extends StatelessWidget { ], ), ), + BitboxNotInitialized() => ConnectContent( + title: S.of(context).connectBitboxNotInitializedTitle, + imagePath: 'assets/images/illustrations/bitbox_connect.svg', + onConfirm: () => context.read().recheckDeviceStatus(), + onCancel: onCancel ?? context.pop, + confirmLabel: S.of(context).retry, + child: Text( + S.of(context).connectBitboxNotInitialized, + textAlign: .center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: RealUnitColors.neutral500, + ), + ), + ), BitboxCapturingSignature() => ConnectContent( title: S.of(context).connectBitboxSignatureCapturingTitle, imagePath: 'assets/images/illustrations/bitbox_connected.svg', diff --git a/pubspec.lock b/pubspec.lock index d8b9567b..795c516b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -77,8 +77,8 @@ packages: dependency: "direct main" description: path: "." - ref: "v0.0.9" - resolved-ref: "6172a2e76ccb5f45a92a37e89f92ff224e0ca4d1" + ref: "v0.0.10" + resolved-ref: "cd99ce656410e8df6c6585076d6ee0205a67b34c" url: "https://github.com/DFXswiss/bitbox_flutter.git" source: git version: "0.0.1" diff --git a/pubspec.yaml b/pubspec.yaml index fce1f4d8..6f5b347e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -79,7 +79,7 @@ dependencies: bitbox_flutter: git: url: https://github.com/DFXswiss/bitbox_flutter.git - ref: v0.0.9 + ref: v0.0.10 dev_dependencies: flutter_test: diff --git a/test/packages/hardware_wallet/bitbox_service_test.dart b/test/packages/hardware_wallet/bitbox_service_test.dart index 43ee21c8..b9107444 100644 --- a/test/packages/hardware_wallet/bitbox_service_test.dart +++ b/test/packages/hardware_wallet/bitbox_service_test.dart @@ -382,6 +382,26 @@ void main() { }); }); + test('getDeviceStatus returns the firmware status the device reports', () { + // Thin pass-through to the plugin's cached-status read. The cubit branches + // on this string after pairing to detect an unseeded device, so a + // method-name flip would silently break that gate on real hardware. + fakeAsync((async) { + final service = pairedServiceSync(async); + platform.when( + SimulatedBitboxMethod.getDeviceStatus, + (_) async => 'uninitialized', + ); + + String? status; + service.getDeviceStatus().then((value) => status = value); + async.flushMicrotasks(); + + expect(status, 'uninitialized'); + expect(platform.count(SimulatedBitboxMethod.getDeviceStatus), 1); + }); + }); + test('confirmPairing returns normally on a verified channel', () { // Happy path: user pressed the on-device button. fakeAsync((async) { diff --git a/test/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit_test.dart b/test/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit_test.dart index 11b764c5..2d2b0a98 100644 --- a/test/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit_test.dart +++ b/test/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit_test.dart @@ -50,6 +50,10 @@ void main() { when(() => service.startScan()).thenAnswer((_) async => true); when(() => service.getAllUsbDevices()).thenAnswer((_) async => []); when(() => service.startConnectionStatusObserver()).thenReturn(null); + // Default to a device that has a wallet set up so the existing pairing + // tests reach the address-derivation path. The unseeded path is exercised + // explicitly below. + when(() => service.getDeviceStatus()).thenAnswer((_) async => 'initialized'); when(() => walletService.createBitboxWallet(any())).thenAnswer((_) async => wallet); when(() => wallet.currentAccount).thenReturn(_FakeBitboxWalletAccount()); when(() => authService.ensureSignatureFor(any())).thenAnswer((_) async {}); @@ -453,5 +457,121 @@ void main() { expect(cubit.state, isA()); verify(() => walletService.createBitboxWallet('Luke-Skywallet')).called(1); }); + + // A brand-new device with no wallet (firmware status `uninitialized`) cannot + // derive an address. It must surface BitboxNotInitialized — not the generic + // failure — and must NOT try to create a wallet or arm the re-scan timer that + // would re-pair the device in an endless loop. + test('emits BitboxNotInitialized when the device has no wallet set up', () async { + var pollCount = 0; + when(() => service.getAllUsbDevices()).thenAnswer((_) async => [device]); + when(() => service.init(any())).thenAnswer((_) async => true); + when(() => service.getChannelHash()).thenAnswer((_) async { + pollCount++; + return pollCount < 3 ? '' : 'HASH-ok'; + }); + when(() => service.confirmPairing()).thenAnswer((_) async {}); + when(() => service.getDeviceStatus()).thenAnswer((_) async => 'uninitialized'); + + final cubit = makeCubit(); + addTearDown(cubit.close); + + await waitForState(cubit); + await cubit.confirmPairing(); + + expect(cubit.state, isA()); + verifyNever(() => walletService.createBitboxWallet(any())); + + // The state must be stable: no re-scan timer is armed, so the cubit does + // not bounce back through the connection flow on its own. + await Future.delayed(const Duration(milliseconds: 50)); + expect(cubit.state, isA()); + }); + + test('recheckDeviceStatus continues to BitboxConnected once a wallet is set up', () async { + var pollCount = 0; + var statusCalls = 0; + when(() => service.getAllUsbDevices()).thenAnswer((_) async => [device]); + when(() => service.init(any())).thenAnswer((_) async => true); + when(() => service.getChannelHash()).thenAnswer((_) async { + pollCount++; + return pollCount < 3 ? '' : 'HASH-ok'; + }); + when(() => service.confirmPairing()).thenAnswer((_) async {}); + when(() => service.getDeviceStatus()).thenAnswer((_) async { + statusCalls++; + return statusCalls == 1 ? 'uninitialized' : 'initialized'; + }); + + final cubit = makeCubit(); + addTearDown(cubit.close); + + await waitForState(cubit); + await cubit.confirmPairing(); + expect(cubit.state, isA()); + + await cubit.recheckDeviceStatus(); + expect(cubit.state, isA()); + verify(() => walletService.createBitboxWallet(any())).called(1); + }); + + test( + 'recheckDeviceStatus stays in BitboxNotInitialized while the device is still unseeded', + () async { + var pollCount = 0; + when(() => service.getAllUsbDevices()).thenAnswer((_) async => [device]); + when(() => service.init(any())).thenAnswer((_) async => true); + when(() => service.getChannelHash()).thenAnswer((_) async { + pollCount++; + return pollCount < 3 ? '' : 'HASH-ok'; + }); + when(() => service.confirmPairing()).thenAnswer((_) async {}); + when(() => service.getDeviceStatus()).thenAnswer((_) async => 'uninitialized'); + + final cubit = makeCubit(); + addTearDown(cubit.close); + + await waitForState(cubit); + await cubit.confirmPairing(); + expect(cubit.state, isA()); + + await cubit.recheckDeviceStatus(); + expect(cubit.state, isA()); + verifyNever(() => walletService.createBitboxWallet(any())); + }, + ); + + test('recheckDeviceStatus is a no-op when not in BitboxNotInitialized', () async { + final cubit = makeCubit(); + addTearDown(cubit.close); + + await cubit.recheckDeviceStatus(); + expect(cubit.state, isA()); + }); + + // Fail-open guarantee: a failing status read must NOT block a device that + // would otherwise pair. If getDeviceStatus throws, the flow falls through to + // the normal acquire path and still reaches BitboxConnected — the new gate + // can only ever ADD the unseeded path, never break the working one. + test('continues to BitboxConnected when the status read throws', () async { + var pollCount = 0; + when(() => service.getAllUsbDevices()).thenAnswer((_) async => [device]); + when(() => service.init(any())).thenAnswer((_) async => true); + when(() => service.getChannelHash()).thenAnswer((_) async { + pollCount++; + return pollCount < 3 ? '' : 'HASH-ok'; + }); + when(() => service.confirmPairing()).thenAnswer((_) async {}); + when(() => service.getDeviceStatus()).thenThrow(Exception('status boom')); + + final cubit = makeCubit(); + addTearDown(cubit.close); + + await waitForState(cubit); + await cubit.confirmPairing(); + + expect(cubit.state, isA()); + verify(() => walletService.createBitboxWallet(any())).called(1); + }); }); } diff --git a/test/screens/hardware_connect_bitbox/connect_bitbox_view_test.dart b/test/screens/hardware_connect_bitbox/connect_bitbox_view_test.dart index 478ad0d0..096c0ea0 100644 --- a/test/screens/hardware_connect_bitbox/connect_bitbox_view_test.dart +++ b/test/screens/hardware_connect_bitbox/connect_bitbox_view_test.dart @@ -1,3 +1,4 @@ +import 'package:bitbox_flutter/bitbox_flutter.dart' as sdk; import 'package:bloc_test/bloc_test.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; @@ -20,6 +21,8 @@ class _MockConnectBitboxCubit extends MockCubit class _MockBitboxWallet extends Mock implements BitboxWallet {} +class _FakeBitboxDevice extends Fake implements sdk.BitboxDevice {} + void main() { late _MockConnectBitboxCubit cubit; late _MockBitboxWallet wallet; @@ -105,6 +108,23 @@ void main() { verify(() => cubit.continueWithoutSignature()).called(1); }); + testWidgets('BitboxNotInitialized shows retry and cancel buttons', (tester) async { + final device = _FakeBitboxDevice(); + await pumpView(tester, BitboxNotInitialized(device)); + + expect(find.byType(AppFilledButton), findsNWidgets(2)); + }); + + testWidgets('BitboxNotInitialized retry button calls recheckDeviceStatus', (tester) async { + final device = _FakeBitboxDevice(); + when(() => cubit.recheckDeviceStatus()).thenAnswer((_) async {}); + await pumpView(tester, BitboxNotInitialized(device)); + + final buttons = tester.widgetList(find.byType(AppFilledButton)).toList(); + buttons[0].onPressed?.call(); + verify(() => cubit.recheckDeviceStatus()).called(1); + }); + testWidgets('ConnectContent honors confirmLabel and cancelLabel overrides', (tester) async { await tester.pumpApp( ConnectContent( From 8c08820ddce31eac5732f542a01fa56bee1995f0 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 10 Jun 2026 10:47:55 +0200 Subject: [PATCH 5/5] test: cover recheckDeviceStatus catch branch (#730) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Adds one missing test for the `catch` block in `recheckDeviceStatus()` (lines 242–245 in `connect_bitbox_cubit.dart`) - The scenario: device was `uninitialized` on first pair → `BitboxNotInitialized`; user sets up wallet; `recheckDeviceStatus` is called but `createBitboxWallet` throws → cubit must fall back to `BitboxNotConnected` - Fixes Coverage Floor Gate failing at 99.9% (4924/4928 lines) in PR #728 ## Test plan - [ ] `flutter test test/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit_test.dart` passes - [ ] Coverage Floor Gate reaches 100% --- .../bloc/connect_bitbox_cubit_test.dart | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/test/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit_test.dart b/test/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit_test.dart index 2d2b0a98..f419237d 100644 --- a/test/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit_test.dart +++ b/test/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit_test.dart @@ -549,6 +549,36 @@ void main() { expect(cubit.state, isA()); }); + test('recheckDeviceStatus falls back to NotConnected when acquireWallet throws', () async { + var pollCount = 0; + when(() => service.getAllUsbDevices()).thenAnswer((_) async => [device]); + when(() => service.init(any())).thenAnswer((_) async => true); + when(() => service.getChannelHash()).thenAnswer((_) async { + pollCount++; + return pollCount < 3 ? '' : 'HASH-ok'; + }); + when(() => service.confirmPairing()).thenAnswer((_) async {}); + // First call: device unseeded → BitboxNotInitialized. + // Second call (recheckDeviceStatus): device now seeded, but wallet + // acquisition fails → catch block must emit BitboxNotConnected. + var statusCalls = 0; + when(() => service.getDeviceStatus()).thenAnswer((_) async { + statusCalls++; + return statusCalls == 1 ? 'uninitialized' : 'initialized'; + }); + when(() => walletService.createBitboxWallet(any())).thenThrow(Exception('wallet boom')); + + final cubit = makeCubit(); + addTearDown(cubit.close); + + await waitForState(cubit); + await cubit.confirmPairing(); + expect(cubit.state, isA()); + + await cubit.recheckDeviceStatus(); + expect(cubit.state, isA()); + }); + // Fail-open guarantee: a failing status read must NOT block a device that // would otherwise pair. If getDeviceStatus throws, the flow falls through to // the normal acquire path and still reaches BitboxConnected — the new gate