Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import 'package:bb_mobile/core/utils/build_context_x.dart';
import 'package:bb_mobile/core/widgets/loading/loading_box_content.dart';
import 'package:bb_mobile/core/widgets/loading/loading_line_content.dart';
import 'package:bb_mobile/core/widgets/snackbar_utils.dart';
import 'package:bb_mobile/features/exchange/ui/exchange_router.dart';
import 'package:bb_mobile/features/exchange_support_chat/ui/exchange_support_chat_router.dart';
Expand All @@ -13,7 +15,6 @@ import 'package:flutter/material.dart'
AlertDialog,
BorderRadius,
BoxDecoration,
CircularProgressIndicator,
Container,
Icons,
Navigator,
Expand Down Expand Up @@ -110,7 +111,7 @@ class _FiatSettlementEditorView extends StatelessWidget {
Widget _body(FiatSettlementEditorState state) {
switch (state.status) {
case FiatSettlementEditorStatus.loading:
return const Center(child: CircularProgressIndicator());
return const _LoadingConfiguration();
case FiatSettlementEditorStatus.loadError:
return const _LoadError();
case FiatSettlementEditorStatus.ready:
Expand All @@ -121,6 +122,42 @@ class _FiatSettlementEditorView extends StatelessWidget {
}
}

/// The editor cannot be drawn before the server answers with this product's
/// current payout configuration, and that read is the whole wait between the
/// activation success and the chooser. State what is being waited on, in the
/// app's own loading treatment rather than a bare spinner.
class _LoadingConfiguration extends StatelessWidget {
const _LoadingConfiguration();

@override
Widget build(BuildContext context) {
return Semantics(
liveRegion: true,
label: context.loc.getPaidFiatSettlementLoadingStatus,
child: Padding(
padding: const EdgeInsets.only(top: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Text(
context.loc.getPaidFiatSettlementLoadingStatus,
textAlign: TextAlign.center,
style: context.bullText.bodyMedium,
),
),
const Gap(24),
const LoadingBoxContent(height: 72),
const LoadingLineContent(),
const LoadingLineContent(width: 220),
],
),
),
);
}
}

class _LoadError extends StatelessWidget {
const _LoadError();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -605,6 +605,19 @@ class LightningAddressActivationCubit
.localPreparation,
) =>
LightningAddressActivationFailure.setupFailed,
// A timeout or an unreachable server produced no answer to interpret:
// report that plainly and offer a retry, rather than telling the user the
// outcome is unknown. If the claim did land, the retry reconciles it
// through the NymAlreadyAssigned path.
WalletOwnedLightningAddressActivationException(
phase: WalletOwnedLightningAddressActivationFailurePhase
.registrationSubmission,
cause: LightningAddressException(
kind: LightningAddressErrorKind.timeout ||
LightningAddressErrorKind.network,
),
) =>
LightningAddressActivationFailure.noServerResponse,
WalletOwnedLightningAddressActivationException(
phase: WalletOwnedLightningAddressActivationFailurePhase
.registrationSubmission,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ enum LightningAddressActivationFailure {
lookupFailed,
noDefaultBitcoinWallet,
setupFailed,

/// The claim reached no answer at all — a timeout or an unreachable server.
/// Distinct from [submissionUncertain]: the server said nothing, so there is
/// no half-known outcome to check, only a claim to retry.
noServerResponse,
submissionUncertain,
rejected,
serverTemporary,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -194,24 +194,49 @@ class _LightningAddressActivationScreenState
LightningAddressActivationFailure
.noDefaultBitcoinWallet
? const _NoDefaultBitcoinWalletView()
: state.failure ==
LightningAddressActivationFailure.noServerResponse
? _ServerOutcomeView(
title: context
.loc
.lightningAddressNoServerResponseTitle,
body: context
.loc
.lightningAddressNoServerResponseBody,
actionLabel:
context.loc.lightningAddressClaimRetryButton,
// Straight to the cubit: it re-validates the nym it
// still holds, and no form is mounted on this view.
onAction: context
.read<LightningAddressActivationCubit>()
.submit,
walletBehavior: state.walletBehavior,
walletBehaviorSaving: state.walletBehaviorSaving,
)
: state.failure ==
LightningAddressActivationFailure
.submissionUncertain
? _UncertainSubmissionView(
onCheckStatus: context
? _ServerOutcomeView(
title: context.loc.lightningAddressUncertainTitle,
body: context.loc.lightningAddressUncertainBody,
actionLabel:
context.loc.lightningAddressCheckStatusButton,
onAction: context
.read<LightningAddressActivationCubit>()
.load,
body: context.loc.lightningAddressUncertainBody,
walletBehavior: state.walletBehavior,
walletBehaviorSaving: state.walletBehaviorSaving,
)
: state.failure ==
LightningAddressActivationFailure.toggleUncertain
? _UncertainSubmissionView(
onCheckStatus: context
? _ServerOutcomeView(
title: context.loc.lightningAddressUncertainTitle,
body: context.loc.lightningAddressToggleUncertain,
actionLabel:
context.loc.lightningAddressCheckStatusButton,
onAction: context
.read<LightningAddressActivationCubit>()
.load,
body: context.loc.lightningAddressToggleUncertain,
walletBehavior: state.walletBehavior,
walletBehaviorSaving: state.walletBehaviorSaving,
)
Expand Down Expand Up @@ -287,6 +312,8 @@ class _LightningAddressActivationScreenState
context.loc.lightningAddressNoDefaultBitcoinWalletError,
LightningAddressActivationFailure.setupFailed =>
context.loc.lightningAddressSetupFailed,
LightningAddressActivationFailure.noServerResponse =>
context.loc.lightningAddressNoServerResponseBody,
LightningAddressActivationFailure.submissionUncertain =>
context.loc.lightningAddressUncertainBody,
LightningAddressActivationFailure.rejected =>
Expand Down Expand Up @@ -574,15 +601,23 @@ class _AddressUnavailableView extends StatelessWidget {
}
}

class _UncertainSubmissionView extends StatelessWidget {
final VoidCallback onCheckStatus;
/// An outcome the server left the app to explain: either nothing came back at
/// all, or something came back that leaves the claim half-known. Each caller
/// supplies its own wording and its own single next action — retry the claim, or
/// re-read the status — so neither case borrows the other's story.
class _ServerOutcomeView extends StatelessWidget {
final String title;
final String body;
final String actionLabel;
final VoidCallback onAction;
final GetPaidWalletBehavior? walletBehavior;
final bool walletBehaviorSaving;

const _UncertainSubmissionView({
required this.onCheckStatus,
const _ServerOutcomeView({
required this.title,
required this.body,
required this.actionLabel,
required this.onAction,
required this.walletBehavior,
required this.walletBehaviorSaving,
});
Expand All @@ -592,17 +627,14 @@ class _UncertainSubmissionView extends StatelessWidget {
return ListView(
padding: const EdgeInsets.all(16),
children: [
_StatusNotice(
icon: Icons.help_outline,
title: context.loc.lightningAddressUncertainTitle,
body: body,
),
_StatusNotice(icon: Icons.help_outline, title: title, body: body),
const Gap(24),
BBButton.big(
label: context.loc.lightningAddressCheckStatusButton,
key: const Key('lightning_address_server_outcome_action'),
label: actionLabel,
iconData: Icons.refresh,
iconFirst: true,
onPressed: onCheckStatus,
onPressed: onAction,
bgColor: context.appColors.secondary,
textColor: context.appColors.onSecondary,
),
Expand Down
16 changes: 16 additions & 0 deletions localization/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -17688,5 +17688,21 @@
"getPaidWalletHideOnHomeNeedsAutoSweep": "Available only while auto-sweep is on: a wallet keeping its funds stays on your home list.",
"@getPaidWalletHideOnHomeNeedsAutoSweep": {
"description": "Explains why Hide on home is unavailable: the wallet must be swept for hiding to be safe."
},
"lightningAddressNoServerResponseTitle": "The server did not respond",
"@lightningAddressNoServerResponseTitle": {
"description": "Title shown when a Lightning Address claim timed out or the naming server was unreachable."
},
"lightningAddressNoServerResponseBody": "Bull Bitcoin did not answer in time, so nothing was claimed and nothing changed. Check your connection and try again.",
"@lightningAddressNoServerResponseBody": {
"description": "Body for a timed-out or unreachable Lightning Address claim: states that no name was claimed and to retry."
},
"lightningAddressClaimRetryButton": "Try again",
"@lightningAddressClaimRetryButton": {
"description": "Retries the Lightning Address claim after the server did not respond."
},
"getPaidFiatSettlementLoadingStatus": "Checking your current payout settings with Bull Bitcoin...",
"@getPaidFiatSettlementLoadingStatus": {
"description": "Explains the server round-trip the fiat settlement editor performs while it opens."
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import 'dart:async';

import 'package:bb_mobile/core/themes/app_theme.dart';
import 'package:bb_mobile/core/utils/result.dart';
import 'package:bb_mobile/features/fiat_settlement/public/fiat_settlement_facade.dart';
import 'package:bb_mobile/features/fiat_settlement/ui/screens/fiat_settlement_editor_screen.dart';
import 'package:bb_mobile/generated/l10n/localization.dart';
import 'package:bb_mobile/locator.dart';
import 'package:bb_mobile/core/widgets/loading/loading_box_content.dart';
import 'package:bull_ui/bull_ui.dart' show BullButton;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
Expand Down Expand Up @@ -115,4 +118,40 @@ void main() {
expect(_button('Reconnect Bull Bitcoin'), findsOneWidget);
expect(tester.takeException(), isNull);
});

testWidgets('the wait for the server config is named, in the app\'s own '
'loading treatment', (tester) async {
final answer =
Completer<
Result<FiatSettlementConfigurationView, FiatSettlementFailure>
>();
when(() => facade.configuration()).thenAnswer((_) => answer.future);

await tester.binding.setSurfaceSize(const Size(1080, 3200));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.themeData(AppThemeType.light),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('en'),
home: const FiatSettlementEditorScreen(
product: product,
activated: true,
),
),
);
await tester.pump();

// Post-activation, this read is the whole wait before the chooser appears.
expect(
find.text('Checking your current payout settings with Bull Bitcoin...'),
findsOneWidget,
);
expect(find.byType(LoadingBoxContent), findsOneWidget);
expect(find.byType(CircularProgressIndicator), findsNothing);

answer.complete(Ok(_view(product, 0)));
await tester.pumpAndSettle();
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,79 @@ void main() {
},
);

test('a claim the server never answered is not called uncertain', () async {
await _loadFirstClaim(cubit, lookup);
cubit.nymChanged('alice');
activate
.error = WalletOwnedLightningAddressActivationException.fromRegistration(
WalletOwnedLightningAddressRegistrationException.registrationSubmission(
cause: const LightningAddressTimeoutException(
code: 'Timeout',
retryable: true,
),
walletId: 'wallet-101',
walletCreated: false,
),
);

await cubit.submit();

// Nothing came back, so there is no half-known outcome to check — the nym
// is unclaimed and the nym typed stays available to retry with.
expect(
cubit.state.failure,
LightningAddressActivationFailure.noServerResponse,
);
expect(cubit.state.hasPermanentNym, isFalse);
expect(cubit.state.nym, 'alice');
});

test('an unreachable server reads the same as a timeout', () async {
await _loadFirstClaim(cubit, lookup);
cubit.nymChanged('alice');
activate
.error = WalletOwnedLightningAddressActivationException.fromRegistration(
WalletOwnedLightningAddressRegistrationException.registrationSubmission(
cause: const LightningAddressNetworkException(
code: 'Network',
retryable: true,
),
walletId: 'wallet-101',
walletCreated: false,
),
);

await cubit.submit();

expect(
cubit.state.failure,
LightningAddressActivationFailure.noServerResponse,
);
});

test('a garbled server answer stays genuinely uncertain', () async {
await _loadFirstClaim(cubit, lookup);
cubit.nymChanged('alice');
activate
.error = WalletOwnedLightningAddressActivationException.fromRegistration(
WalletOwnedLightningAddressRegistrationException.registrationSubmission(
cause: const LightningAddressInvalidServerResponseException(
code: 'InvalidServerResponse',
retryable: true,
),
walletId: 'wallet-101',
walletCreated: false,
),
);

await cubit.submit();

expect(
cubit.state.failure,
LightningAddressActivationFailure.submissionUncertain,
);
});

test(
'active local setup failure preserves online ownership state',
() async {
Expand Down
Loading
Loading