From 81ec0069a305bf6d7a68f0b262d5ccdcfa297036 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 9 Jun 2026 15:13:34 +0200 Subject: [PATCH 1/3] fix(home): catch recovery-gate failure + pin error surface in a test Follow-up to #710. - Move the BitBox address-recovery gate inside _onLoadCurrentWallet's try/catch so an unexpected throw from currentWalletNeedsAddressRecovery is handled as a load failure (spinner cleared) instead of leaving isLoadingWallet stuck true. Success and recovery paths are unchanged. - Add a widget test for the ErrorWidget.builder last-resort surface via a @visibleForTesting factory, covering the friendly copy, the error icon, and the debug-only exception text. --- lib/main.dart | 7 ++++++ lib/screens/home/bloc/home_bloc.dart | 20 ++++++++------- test/main_error_view_test.dart | 37 ++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 9 deletions(-) create mode 100644 test/main_error_view_test.dart diff --git a/lib/main.dart b/lib/main.dart index ae15497b..6dc82807 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -168,6 +168,13 @@ class _WalletAppState extends State { } } +/// Test-only seam for [_RealUnitErrorView]. The widget is private (so +/// [ErrorWidget.builder] can't be swapped from outside), but its last-resort +/// rendering is worth pinning. Constructs exactly what [_installErrorHandlers] +/// wires into [ErrorWidget.builder]. +@visibleForTesting +Widget buildRealUnitErrorView(FlutterErrorDetails details) => _RealUnitErrorView(details: details); + /// Minimal on-brand replacement for the default grey [ErrorWidget]. Rendered by /// [ErrorWidget.builder] for any uncaught build/paint exception, so it lives /// outside the [MaterialApp] localization scope — the copy is deliberately diff --git a/lib/screens/home/bloc/home_bloc.dart b/lib/screens/home/bloc/home_bloc.dart index f9f6569d..6fd9d36b 100644 --- a/lib/screens/home/bloc/home_bloc.dart +++ b/lib/screens/home/bloc/home_bloc.dart @@ -57,16 +57,18 @@ class HomeBloc extends Bloc { emit(state.copyWith(isLoadingWallet: true)); - // Self-heal gate: a BitBox row persisted with an empty/invalid address would - // crash the dashboard build via `EthereumAddress.fromHex("")`. Divert to the - // address-recovery pairing flow before we ever load such a wallet into the - // AppStore. Non-throwing — a healthy wallet returns false and falls through. - if (await _walletService.currentWalletNeedsAddressRecovery()) { - emit(state.copyWith(isLoadingWallet: false, bitboxAddressRecoveryNeeded: true)); - return; - } - try { + // Self-heal gate: a BitBox row persisted with an empty/invalid address would + // crash the dashboard build via `EthereumAddress.fromHex("")`. Divert to the + // address-recovery pairing flow before we ever load such a wallet into the + // AppStore. A healthy wallet returns false and falls through; sits inside the + // try so an unexpected throw is handled as a load failure below (spinner + // cleared) instead of leaving `isLoadingWallet` stuck true. + if (await _walletService.currentWalletNeedsAddressRecovery()) { + emit(state.copyWith(isLoadingWallet: false, bitboxAddressRecoveryNeeded: true)); + return; + } + final wallet = await _walletService.getCurrentWallet(); _appStore.wallet = wallet; diff --git a/test/main_error_view_test.dart b/test/main_error_view_test.dart new file mode 100644 index 00000000..b221f248 --- /dev/null +++ b/test/main_error_view_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +// `show` the test seam only — main.dart also declares a top-level `main`, which +// would otherwise clash with this file's own `main`. +import 'package:realunit_wallet/main.dart' show buildRealUnitErrorView; + +void main() { + // Pins the last-resort surface wired into `ErrorWidget.builder` in `main`. + // It must render even when an uncaught build error escapes the MaterialApp / + // localization scope, so the view brings its own Directionality and renders + // as a bare root widget — these tests pump it with no surrounding app. + group('RealUnit error view (ErrorWidget.builder surface)', () { + testWidgets('renders the friendly copy and error icon with no surrounding app', (tester) async { + await tester.pumpWidget( + buildRealUnitErrorView(FlutterErrorDetails(exception: Exception('boom'))), + ); + + expect(find.text('Something went wrong. Please restart the app.'), findsOneWidget); + expect(find.byIcon(Icons.error_outline), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('surfaces the exception text in debug builds', (tester) async { + await tester.pumpWidget( + buildRealUnitErrorView( + FlutterErrorDetails(exception: Exception('diagnostic-marker-123')), + ), + ); + + // `flutter test` runs in debug mode, so the kDebugMode branch is live and + // the raw exception text is surfaced to speed up diagnosis. + expect(kDebugMode, isTrue); + expect(find.textContaining('diagnostic-marker-123'), findsOneWidget); + }); + }); +} From 65e031001e2e5eb7da789d9aa2d566aaa471ed6b Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 9 Jun 2026 15:23:53 +0200 Subject: [PATCH 2/3] test(home): cover recovery-gate throw is caught as a load failure Directly pins the behaviour the previous commit introduced: when currentWalletNeedsAddressRecovery throws, _onLoadCurrentWallet clears the spinner, leaves bitboxAddressRecoveryNeeded off, and skips the sync side-effects instead of leaving isLoadingWallet stuck true. --- test/screens/home/home_bloc_test.dart | 29 +++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/test/screens/home/home_bloc_test.dart b/test/screens/home/home_bloc_test.dart index 70a3ace6..7f2ef30a 100644 --- a/test/screens/home/home_bloc_test.dart +++ b/test/screens/home/home_bloc_test.dart @@ -238,6 +238,35 @@ void main() { verifyNever(() => transactionHistoryService.apiBasedSync()); }, ); + + test( + 'recovery gate throws → caught as load failure, spinner cleared, recovery flag stays off', + () async { + when(() => walletService.hasWallet()).thenReturn(true); + // The gate runs inside the load try/catch, so an unexpected throw + // (e.g. a corrupt wallet-type index) is handled as a load failure + // instead of leaving isLoadingWallet stuck true — and must NOT divert + // the app into the recovery flow. + when( + () => walletService.currentWalletNeedsAddressRecovery(), + ).thenThrow(Exception('gate boom')); + + final bloc = build(); + await bloc.stream.firstWhere((s) => s.hasWallet); + + bloc.add(const LoadCurrentWalletEvent()); + await bloc.stream.firstWhere( + (s) => s.isLoadingWallet == false && s.openWallet == null, + ); + + expect(bloc.state.isLoadingWallet, isFalse); + expect(bloc.state.bitboxAddressRecoveryNeeded, isFalse); + expect(bloc.state.openWallet, isNull); + verifyNever(() => walletService.getCurrentWallet()); + verifyNever(() => balanceService.updateBalance(any())); + verifyNever(() => transactionHistoryService.apiBasedSync()); + }, + ); }); group('LoadWalletEvent', () { From 300286575e9e6698b261aae71735cd57f7973dfc Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 9 Jun 2026 15:52:37 +0200 Subject: [PATCH 3/3] refactor(main): extract last-resort error view to its own file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move RealUnitErrorView out of main.dart into lib/setup/error_handling/ so its widget test imports only the view, not main.dart. Importing main.dart pulled fuck_firebase.dart — which is inside the coverage scope (lib/packages/**) — into the report with its never-executed body, dropping scoped line coverage to 99.9% and failing the Coverage Floor Gate. The view now lives outside the coverage scope and is still tested directly via the public RealUnitErrorView. --- lib/main.dart | 55 +------------------ .../error_handling/realunit_error_view.dart | 52 ++++++++++++++++++ .../realunit_error_view_test.dart} | 12 ++-- 3 files changed, 59 insertions(+), 60 deletions(-) create mode 100644 lib/setup/error_handling/realunit_error_view.dart rename test/{main_error_view_test.dart => setup/error_handling/realunit_error_view_test.dart} (72%) diff --git a/lib/main.dart b/lib/main.dart index 6dc82807..b9917025 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -11,12 +11,12 @@ import 'package:realunit_wallet/screens/home/bloc/home_bloc.dart'; import 'package:realunit_wallet/screens/pin/bloc/auth/pin_auth_cubit.dart'; import 'package:realunit_wallet/screens/settings/bloc/settings_bloc.dart'; import 'package:realunit_wallet/setup/di.dart'; +import 'package:realunit_wallet/setup/error_handling/realunit_error_view.dart'; import 'package:realunit_wallet/setup/lifecycle_initializer.dart'; import 'package:realunit_wallet/setup/routing/router_config.dart'; import 'package:realunit_wallet/setup/routing/routes/app_routes.dart'; import 'package:realunit_wallet/setup/routing/routes/onboarding_routes.dart'; import 'package:realunit_wallet/setup/routing/routes/pin_routes.dart'; -import 'package:realunit_wallet/styles/colors.dart'; import 'package:realunit_wallet/styles/themes.dart'; Future main() async { @@ -59,7 +59,7 @@ void _installErrorHandlers() { ); defaultOnError?.call(details); }; - ErrorWidget.builder = (details) => _RealUnitErrorView(details: details); + ErrorWidget.builder = (details) => RealUnitErrorView(details: details); } Future _initializeWithSplashDuration() async { @@ -167,54 +167,3 @@ class _WalletAppState extends State { routerConfig.goNamed(targetRoute); } } - -/// Test-only seam for [_RealUnitErrorView]. The widget is private (so -/// [ErrorWidget.builder] can't be swapped from outside), but its last-resort -/// rendering is worth pinning. Constructs exactly what [_installErrorHandlers] -/// wires into [ErrorWidget.builder]. -@visibleForTesting -Widget buildRealUnitErrorView(FlutterErrorDetails details) => _RealUnitErrorView(details: details); - -/// Minimal on-brand replacement for the default grey [ErrorWidget]. Rendered by -/// [ErrorWidget.builder] for any uncaught build/paint exception, so it lives -/// outside the [MaterialApp] localization scope — the copy is deliberately -/// hardcoded (no `S.of(context)`) because this is a last-resort surface that -/// must render even when localization is unavailable. In debug it also shows -/// the exception text to keep diagnosis fast; release shows only the friendly -/// line. -class _RealUnitErrorView extends StatelessWidget { - const _RealUnitErrorView({required this.details}); - - final FlutterErrorDetails details; - - @override - Widget build(BuildContext context) => Directionality( - textDirection: TextDirection.ltr, - child: Container( - color: RealUnitColors.neutral50, - alignment: Alignment.center, - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - spacing: 12, - children: [ - Icon(Icons.error_outline, color: RealUnitColors.status.red600, size: 48), - const Text( - 'Something went wrong. Please restart the app.', - textAlign: TextAlign.center, - style: TextStyle( - color: RealUnitColors.neutral900, - fontWeight: FontWeight.w600, - ), - ), - if (kDebugMode) - Text( - details.exceptionAsString(), - textAlign: TextAlign.center, - style: const TextStyle(color: RealUnitColors.neutral500), - ), - ], - ), - ), - ); -} diff --git a/lib/setup/error_handling/realunit_error_view.dart b/lib/setup/error_handling/realunit_error_view.dart new file mode 100644 index 00000000..1b2564b3 --- /dev/null +++ b/lib/setup/error_handling/realunit_error_view.dart @@ -0,0 +1,52 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:realunit_wallet/styles/colors.dart'; + +/// Minimal on-brand replacement for the default grey [ErrorWidget]. Wired into +/// [ErrorWidget.builder] in `main` and rendered for any uncaught build/paint +/// exception, so it lives outside the [MaterialApp] localization scope — the +/// copy is deliberately hardcoded (no `S.of(context)`) because this is a +/// last-resort surface that must render even when localization is unavailable. +/// It brings its own [Directionality] so it can paint as a bare root widget. +/// In debug it also shows the exception text to keep diagnosis fast; release +/// shows only the friendly line. +/// +/// Kept in its own file (rather than inline in `main.dart`) so it can be +/// widget-tested without importing `main.dart`, whose transitive imports would +/// otherwise pull untested bootstrap code into the coverage report. +class RealUnitErrorView extends StatelessWidget { + const RealUnitErrorView({super.key, required this.details}); + + final FlutterErrorDetails details; + + @override + Widget build(BuildContext context) => Directionality( + textDirection: TextDirection.ltr, + child: Container( + color: RealUnitColors.neutral50, + alignment: Alignment.center, + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: 12, + children: [ + Icon(Icons.error_outline, color: RealUnitColors.status.red600, size: 48), + const Text( + 'Something went wrong. Please restart the app.', + textAlign: TextAlign.center, + style: TextStyle( + color: RealUnitColors.neutral900, + fontWeight: FontWeight.w600, + ), + ), + if (kDebugMode) + Text( + details.exceptionAsString(), + textAlign: TextAlign.center, + style: const TextStyle(color: RealUnitColors.neutral500), + ), + ], + ), + ), + ); +} diff --git a/test/main_error_view_test.dart b/test/setup/error_handling/realunit_error_view_test.dart similarity index 72% rename from test/main_error_view_test.dart rename to test/setup/error_handling/realunit_error_view_test.dart index b221f248..a31c0457 100644 --- a/test/main_error_view_test.dart +++ b/test/setup/error_handling/realunit_error_view_test.dart @@ -1,19 +1,17 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -// `show` the test seam only — main.dart also declares a top-level `main`, which -// would otherwise clash with this file's own `main`. -import 'package:realunit_wallet/main.dart' show buildRealUnitErrorView; +import 'package:realunit_wallet/setup/error_handling/realunit_error_view.dart'; void main() { // Pins the last-resort surface wired into `ErrorWidget.builder` in `main`. // It must render even when an uncaught build error escapes the MaterialApp / // localization scope, so the view brings its own Directionality and renders // as a bare root widget — these tests pump it with no surrounding app. - group('RealUnit error view (ErrorWidget.builder surface)', () { + group('$RealUnitErrorView (ErrorWidget.builder surface)', () { testWidgets('renders the friendly copy and error icon with no surrounding app', (tester) async { await tester.pumpWidget( - buildRealUnitErrorView(FlutterErrorDetails(exception: Exception('boom'))), + RealUnitErrorView(details: FlutterErrorDetails(exception: Exception('boom'))), ); expect(find.text('Something went wrong. Please restart the app.'), findsOneWidget); @@ -23,8 +21,8 @@ void main() { testWidgets('surfaces the exception text in debug builds', (tester) async { await tester.pumpWidget( - buildRealUnitErrorView( - FlutterErrorDetails(exception: Exception('diagnostic-marker-123')), + RealUnitErrorView( + details: FlutterErrorDetails(exception: Exception('diagnostic-marker-123')), ), );