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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 2 additions & 46 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> main() async {
Expand Down Expand Up @@ -59,7 +59,7 @@ void _installErrorHandlers() {
);
defaultOnError?.call(details);
};
ErrorWidget.builder = (details) => _RealUnitErrorView(details: details);
ErrorWidget.builder = (details) => RealUnitErrorView(details: details);
}

Future<void> _initializeWithSplashDuration() async {
Expand Down Expand Up @@ -167,47 +167,3 @@ class _WalletAppState extends State<WalletApp> {
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),
),
],
),
),
);
}
20 changes: 11 additions & 9 deletions lib/screens/home/bloc/home_bloc.dart
Original file line number Diff line number Diff line change
Expand Up @@ -57,16 +57,18 @@ class HomeBloc extends Bloc<HomeEvent, HomeState> {

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;

Expand Down
52 changes: 52 additions & 0 deletions lib/setup/error_handling/realunit_error_view.dart
Original file line number Diff line number Diff line change
@@ -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),
),
],
),
),
);
}
29 changes: 29 additions & 0 deletions test/screens/home/home_bloc_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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', () {
Expand Down
35 changes: 35 additions & 0 deletions test/setup/error_handling/realunit_error_view_test.dart
Original file line number Diff line number Diff line change
@@ -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);
});
});
}
Loading