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
19 changes: 19 additions & 0 deletions lib/core/exchange/domain/entity/order.dart
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,21 @@ enum OrderPaymentMethod {
final String value;
const OrderPaymentMethod(this.value);

/// The payin/payout methods that debit or credit one of the user's in-app
/// fiat balances instead of an external account. Refund-to-balance methods
/// are deliberately excluded: they are not selectable as a payout.
static const balanceMethods = <OrderPaymentMethod>{
cadBalance,
eurBalance,
mxnBalance,
arsBalance,
copBalance,
crcBalance,
usdBalance,
};

bool get isBalance => balanceMethods.contains(this);

static OrderPaymentMethod fromValue(String value) {
return OrderPaymentMethod.values.firstWhere(
(e) => e.value == value,
Expand Down Expand Up @@ -562,6 +577,10 @@ sealed class Order with _$Order {
bool get isPayinCompleted => payinStatus == OrderPayinStatus.completed;
bool get isPayoutCompleted => payoutStatus == OrderPayoutStatus.completed;

/// Whether the payout credits one of the user's in-app fiat balances rather
/// than an external recipient.
bool get isBalancePayout => payoutMethod.isBalance;

bool isCompleted() => orderStatus == OrderStatus.completed;
bool isProcessing() => orderStatus == OrderStatus.inProgress;
bool isCancelled() => orderStatus == OrderStatus.canceled;
Expand Down
112 changes: 112 additions & 0 deletions lib/core/widgets/success_screen_scaffold.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import 'package:bb_mobile/core/themes/app_theme.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';

/// Shared layout for the exchange flows' success screens (buy, sell, pay) so
/// they stay structurally aligned: a success icon, a headline, an optional
/// amount line and message, and a bottom action area.
///
/// Back navigation is intercepted; the flow can only be left through [onClose],
/// which is called both by the close button and by a back gesture.
class SuccessScreenScaffold extends StatelessWidget {
const SuccessScreenScaffold({
super.key,
required this.title,
required this.headline,
required this.onClose,
this.icon,
this.amountLine,
this.message,
this.actions = const <Widget>[],
});

/// App bar title, usually the name of the flow.
final String title;

/// Headline under the icon, e.g. "Order completed!".
final String headline;

/// Leaves the flow. Called by the close button and by a back gesture.
final VoidCallback onClose;

/// Defaults to a large success check mark.
final Widget? icon;

/// Line under the headline, e.g. "You sold 100 000 sats for $50.00".
final String? amountLine;

/// Explanatory content under the amount line. Styled as centered body text
/// unless the widget overrides it, so a plain [Text] is enough.
final Widget? message;

/// Pinned to the bottom of the screen, typically buttons.
final List<Widget> actions;

@override
Widget build(BuildContext context) {
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (didPop) return;
onClose();
},
child: Scaffold(
appBar: AppBar(
title: Text(title),
automaticallyImplyLeading: false,
actions: [
IconButton(icon: const Icon(Icons.close), onPressed: onClose),
],
),
body: SafeArea(
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0),
child: Column(
mainAxisAlignment: .center,
children: [
icon ??
Icon(
Icons.check_circle,
size: 100,
color: context.appColors.success,
),
const Gap(20),
Text(
headline,
style: context.font.titleLarge,
textAlign: .center,
),
if (amountLine != null) ...[
const Gap(8),
Text(
amountLine!,
style: context.font.bodyLarge,
textAlign: .center,
),
],
if (message != null) ...[
const Gap(10),
DefaultTextStyle.merge(
style: context.font.bodyMedium,
textAlign: .center,
child: message!,
),
],
],
),
),
),
),
bottomNavigationBar: actions.isEmpty
? null
: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(mainAxisSize: .min, children: actions),
),
),
),
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -397,49 +397,43 @@ class RecipientDetailsDto {
);

// COSTA RICA
// ownerName is nullable by design for the SINPE types: the server fills it
// from a Ridivi lookup that can fail or be empty on older records.
// Requiring it here silently dropped those recipients (#2529).
case RecipientType.sinpeIbanUsd:
if (iban == null) {
throw StateError('iban is required for SINPE_IBAN_USD.');
}
if (ownerName == null) {
throw StateError('ownerName is required for SINPE_IBAN_USD.');
}
return SinpeIbanUsdDetails.create(
label: label,
isDefault: def,
isOwner: isOwner,
iban: iban!,
ownerName: ownerName!,
ownerName: ownerName,
);

case RecipientType.sinpeIbanCrc:
if (iban == null) {
throw StateError('iban is required for SINPE_IBAN_CRC.');
}
if (ownerName == null) {
throw StateError('ownerName is required for SINPE_IBAN_CRC.');
}
return SinpeIbanCrcDetails.create(
label: label,
isDefault: def,
isOwner: isOwner,
iban: iban!,
ownerName: ownerName!,
ownerName: ownerName,
);

case RecipientType.sinpeMovilCrc:
if (phoneNumber == null) {
throw StateError('phoneNumber is required for SINPE_MOVIL_CRC.');
}
if (ownerName == null) {
throw StateError('ownerName is required for SINPE_MOVIL_CRC.');
}
return SinpeMovilCrcDetails.create(
label: label,
isDefault: def,
isOwner: isOwner,
phoneNumber: phoneNumber!,
ownerName: ownerName!,
ownerName: ownerName,
);

// ARGENTINA
Expand Down
42 changes: 21 additions & 21 deletions lib/features/recipients/domain/value_objects/recipient_details.dart
Original file line number Diff line number Diff line change
Expand Up @@ -378,39 +378,45 @@ class SpeiCardMxnDetails extends RecipientDetails {
}

// ── SINPE (CRC/USD)
//
// The owner name comes from a Ridivi lookup on the server, which can fail or be
// empty on older records, so it is nullable by design (#2529). Display sites
// fall back to the label and then to the account identifier.
String? _nullIfBlank(String? value) {
final trimmed = value?.trim();
return trimmed == null || trimmed.isEmpty ? null : trimmed;
}

@immutable
class SinpeIbanUsdDetails extends RecipientDetails {
final String iban;
final String ownerName;
final String? ownerName;

const SinpeIbanUsdDetails._({
super.label,
super.isDefault = false,
super.isOwner,
required this.iban,
required this.ownerName,
this.ownerName,
});

factory SinpeIbanUsdDetails.create({
String? label,
bool isDefault = false,
bool? isOwner,
required String iban,
required String ownerName,
String? ownerName,
}) {
if (iban.trim().isEmpty) {
throw ArgumentError('IBAN cannot be empty');
}
if (ownerName.trim().isEmpty) {
throw ArgumentError('Owner name cannot be empty');
}

return SinpeIbanUsdDetails._(
label: label,
isDefault: isDefault,
isOwner: isOwner,
iban: iban.trim(),
ownerName: ownerName.trim(),
ownerName: _nullIfBlank(ownerName),
);
}

Expand All @@ -421,36 +427,33 @@ class SinpeIbanUsdDetails extends RecipientDetails {
@immutable
class SinpeIbanCrcDetails extends RecipientDetails {
final String iban;
final String ownerName;
final String? ownerName;

const SinpeIbanCrcDetails._({
super.label,
super.isDefault = false,
super.isOwner,
required this.iban,
required this.ownerName,
this.ownerName,
});

factory SinpeIbanCrcDetails.create({
String? label,
bool isDefault = false,
bool? isOwner,
required String iban,
required String ownerName,
String? ownerName,
}) {
if (iban.trim().isEmpty) {
throw ArgumentError('IBAN cannot be empty');
}
if (ownerName.trim().isEmpty) {
throw ArgumentError('Owner name cannot be empty');
}

return SinpeIbanCrcDetails._(
label: label,
isDefault: isDefault,
isOwner: isOwner,
iban: iban.trim(),
ownerName: ownerName.trim(),
ownerName: _nullIfBlank(ownerName),
);
}

Expand All @@ -461,36 +464,33 @@ class SinpeIbanCrcDetails extends RecipientDetails {
@immutable
class SinpeMovilCrcDetails extends RecipientDetails {
final String phoneNumber;
final String ownerName;
final String? ownerName;

const SinpeMovilCrcDetails._({
super.label,
super.isDefault = false,
super.isOwner,
required this.phoneNumber,
required this.ownerName,
this.ownerName,
});

factory SinpeMovilCrcDetails.create({
String? label,
bool isDefault = false,
bool? isOwner,
required String phoneNumber,
required String ownerName,
String? ownerName,
}) {
if (phoneNumber.trim().isEmpty) {
throw ArgumentError('Phone number cannot be empty');
}
if (ownerName.trim().isEmpty) {
throw ArgumentError('Owner name cannot be empty');
}

return SinpeMovilCrcDetails._(
label: label,
isDefault: isDefault,
isOwner: isOwner,
phoneNumber: phoneNumber.trim(),
ownerName: ownerName.trim(),
ownerName: _nullIfBlank(ownerName),
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,19 +138,21 @@ sealed class RecipientViewModel with _$RecipientViewModel {
if (label != null && label!.isNotEmpty) return label!;
return null;

// ownerName is often absent for the SINPE types (#2529), so fall all the
// way back to the account identifier rather than showing nothing.
case RecipientType.sinpeIbanUsd:
if (ownerName != null && ownerName!.isNotEmpty) return ownerName!;
if (label != null && label!.isNotEmpty) return label!;
return null;

case RecipientType.sinpeIbanCrc:
if (ownerName != null && ownerName!.isNotEmpty) return ownerName!;
if (label != null && label!.isNotEmpty) return label!;
if (iban != null && iban!.isNotEmpty) return iban!;
return null;

case RecipientType.sinpeMovilCrc:
if (ownerName != null && ownerName!.isNotEmpty) return ownerName!;
if (label != null && label!.isNotEmpty) return label!;
if (phoneNumber != null && phoneNumber!.isNotEmpty) {
return phoneNumber!;
}
return null;
case RecipientType.bankAccountArgentina:
if (name != null && name!.isNotEmpty) return name!;
Expand Down
Loading