diff --git a/lib/main.dart b/lib/main.dart index ae15497bc..b99170254 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,47 +167,3 @@ class _WalletAppState extends State { routerConfig.goNamed(targetRoute); } } - -/// 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/screens/home/bloc/home_bloc.dart b/lib/screens/home/bloc/home_bloc.dart index f9f6569de..6fd9d36ba 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/lib/setup/error_handling/realunit_error_view.dart b/lib/setup/error_handling/realunit_error_view.dart new file mode 100644 index 000000000..1b2564b35 --- /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/screens/home/home_bloc_test.dart b/test/screens/home/home_bloc_test.dart index 70a3ace67..7f2ef30a6 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', () { diff --git a/test/setup/error_handling/realunit_error_view_test.dart b/test/setup/error_handling/realunit_error_view_test.dart new file mode 100644 index 000000000..a31c04575 --- /dev/null +++ b/test/setup/error_handling/realunit_error_view_test.dart @@ -0,0 +1,35 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +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('$RealUnitErrorView (ErrorWidget.builder surface)', () { + testWidgets('renders the friendly copy and error icon with no surrounding app', (tester) async { + await tester.pumpWidget( + RealUnitErrorView(details: 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( + RealUnitErrorView( + details: 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); + }); + }); +}