Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
9b0762f
fix(buy,sell): tell the user an amount is ambiguous instead of failin…
joshuakrueger-dfx Aug 19, 2026
f9a0641
fix(converter): drop the stale counter value when a conversion fails
joshuakrueger-dfx Aug 19, 2026
1df5eb7
feat(buy,sell): resolve a thousands separator in the amount field ins…
joshuakrueger-dfx Aug 19, 2026
d0eaaec
test(goldens): cover the ambiguous-amount error state
joshuakrueger-dfx Aug 19, 2026
b1b0fd8
test(goldens): add the ambiguous-amount baseline
joshuakrueger-dfx Aug 19, 2026
f53215d
test(buy): pin that a changed amount is the one that gets confirmed
joshuakrueger-dfx Aug 20, 2026
6a38394
fix(l10n): describe what actually triggers the unreadable-amount notice
joshuakrueger-dfx Aug 20, 2026
0fa5775
test(goldens): refresh the unreadable-amount baseline for the new wor…
joshuakrueger-dfx Aug 20, 2026
150a21b
fix(buy,sell): close the review findings on the amount field
joshuakrueger-dfx Aug 20, 2026
cd40e64
test(goldens): add the sell unreadable-amount baseline
joshuakrueger-dfx Aug 20, 2026
74e6b4d
test(goldens): drop the sell baseline that could never fail
joshuakrueger-dfx Aug 20, 2026
e3e20b5
fix(buy,sell): resolve mixed separators and restore the sell golden
joshuakrueger-dfx Aug 20, 2026
26a133c
test(goldens): add the sell snackbar baseline
joshuakrueger-dfx Aug 20, 2026
7747988
fix(l10n): state the accepted form instead of the rejected condition
joshuakrueger-dfx Aug 20, 2026
fc63155
test(goldens): refresh both baselines for the reworded notice
joshuakrueger-dfx Aug 20, 2026
0c2c5c9
fix(buy,sell): drop the unreachable sell error branch and fix the caret
joshuakrueger-dfx Aug 20, 2026
87aa017
fix(input): count only the separators dropped before the caret
joshuakrueger-dfx Aug 20, 2026
d9e0914
test(input): pin which separator the caret math removes
joshuakrueger-dfx Aug 20, 2026
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
4 changes: 3 additions & 1 deletion assets/languages/strings_de.arb
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@
"identityCheckProcessDescription": "Als nächstes müssen Sie Ihre Identität verifizieren. Bitte halten Sie Ihren Ausweis bereit und erlauben Sie den Kamerazugriff auf dem Gerät.",
"identityCheckRequired": "Identitätsprüfung erforderlich",
"imprint": "Impressum",
"invalidAmountFormatDescription": "Bitte geben Sie den Betrag zum Beispiel als 1000 oder 1000,50 ein.",
"invalidAmountFormatTitle": "Betrag nicht lesbar",
"kyc": "Eröffnungsprozess",
"kycAccountMergeDescription": "Ihre Identität wurde bereits in einem anderen Konto gefunden. Eine Zusammenführungsanfrage wurde erstellt. Bitte bestätigen Sie diese über die E-Mail, die Sie erhalten haben.",
"kycAccountMergeTitle": "Kontozusammenführung erforderlich",
Expand Down Expand Up @@ -465,4 +467,4 @@
"youPay": "Sie bezahlen",
"youReceive": "Sie erhalten",
"youSell": "Sie verkaufen"
}
}
4 changes: 3 additions & 1 deletion assets/languages/strings_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@
"identityCheckProcessDescription": "Next, you need to verify your identity. Please have your ID ready and allow camera access on your device.",
"identityCheckRequired": "Identity check required",
"imprint": "Imprint",
"invalidAmountFormatDescription": "Please enter the amount as 1000 or 1000.50, for example.",
"invalidAmountFormatTitle": "Amount is not readable",
"kyc": "Onboarding process",
"kycAccountMergeDescription": "Your identity was found in another account. A merge request has been created. Please confirm it via the email you received.",
"kycAccountMergeTitle": "Account merge required",
Expand Down Expand Up @@ -465,4 +467,4 @@
"youPay": "You pay",
"youReceive": "You receive",
"youSell": "You sell"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ enum PaymentInfoError {
bitboxDisconnected,
priceSourceUnavailable,
unknown,
invalidAmountFormat,
}
38 changes: 38 additions & 0 deletions lib/packages/utils/fiat_amount.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,44 @@ double? tryParseFiatAmount(String input) {
return double.tryParse(input.replaceAll(',', '.'));
}

/// Consecutive groups of three digits with the same separator.
final _sameGrouping = RegExp(r'^(\d+)([.,])(\d{3}(?:\2\d{3})*)$');

/// Thousands groups plus 1–2 digits of the other separator.
final _mixedGrouping = RegExp(r'^(\d+)([.,])(\d{3}(?:\2\d{3})*)([.,])(\d{1,2})$');

/// Grouping separator to drop from [input], or null if [input] is unchanged.
String? _groupingSeparatorToStrip(String input) {
final same = _sameGrouping.firstMatch(input);
if (same != null) return same[2]!;
final mixed = _mixedGrouping.firstMatch(input);
if (mixed != null && mixed[2] != mixed[4]) return mixed[2]!;
return null;
}

/// Strips thousands grouping (`1.000` / `1,000` / `1.000.000` → integer).
///
/// EUR and CHF have two decimal places, so a separator followed by exactly
/// three digits cannot be a decimal. Consecutive groups with the same
/// separator are unambiguous thousands grouping, so a paste of `1.000.000`
/// matches what typing the same characters one by one already produced.
/// Mixed thousands + 1–2 decimal digits of the other separator (`1.000,50` /
/// `1,000.50`) drop the thousands separator and keep the decimal, so paste
/// matches typing. Real decimals (`300,75`) and partials (`1.`, `1.0`,
/// `1.00`) are returned unchanged, as is mixed input that is not uniquely
/// thousands-then-decimal (`1.000,000`, `3,5,7`).
String normalizeFiatInput(String input) {
final separator = _groupingSeparatorToStrip(input);
if (separator == null) return input;
return input.replaceAll(separator, '');
}

/// The grouping separator [normalizeFiatInput] strips from [input], or null
/// if [input] is left unchanged.
String? strippedFiatGroupingSeparator(String input) {
return _groupingSeparatorToStrip(input);
}

/// The whole-currency integer the backend charges for the raw [input] the user
/// typed (e.g. `300,75` → `301`); empty input counts as zero.
int chargedFiatAmount(String input) {
Expand Down
10 changes: 9 additions & 1 deletion lib/screens/buy/buy_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,15 @@ class _BuyViewState extends State<BuyView> {
listener: (context, state) {
_syncController(_amountController, state.fiatText);
_syncController(_resultController, state.sharesText);
context.read<BuyPaymentInfoCubit>().getPaymentInfo(
final paymentInfo = context.read<BuyPaymentInfoCubit>();
// An empty field is not an amount to quote. Fetching it would
// charge 0 and, while that request is in flight, leave a previous
// Success (and its confirm button) on screen.
if (_amountController.text.isEmpty) {
paymentInfo.clearQuote();
return;
}
paymentInfo.getPaymentInfo(
amount: _amountController.text,
currency: state.currency,
);
Expand Down
6 changes: 3 additions & 3 deletions lib/screens/buy/cubits/buy_converter/buy_converter_cubit.dart
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ class BuyConverterCubit extends Cubit<BuyConverterState> {
} catch (e) {
developer.log(e.toString());
if (isClosed || mySeq != _seq) return;
emit(state.copyWith(loading: false));
emit(state.copyWith(loading: false, sharesText: ''));
}
});
}
Expand All @@ -75,7 +75,7 @@ class BuyConverterCubit extends Cubit<BuyConverterState> {
} catch (e) {
developer.log(e.toString());
if (isClosed || mySeq != _seq) return;
emit(state.copyWith(loading: false));
emit(state.copyWith(loading: false, fiatText: ''));
}
});
}
Expand All @@ -99,7 +99,7 @@ class BuyConverterCubit extends Cubit<BuyConverterState> {
} catch (e) {
developer.log(e.toString());
if (isClosed || mySeq != _seq) return;
emit(state.copyWith(loading: false));
emit(state.copyWith(loading: false, sharesText: ''));
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,26 @@ class BuyPaymentInfoCubit extends Cubit<BuyPaymentInfoState> {
emit(newState);
}

/// Drops a landed quote so it cannot be confirmed after the amount it
/// belonged to has disappeared. Cancels an in-flight fetch to keep a
/// later completion from restoring the old Success.
void clearQuote() {
_completer?.cancel();
if (isClosed) return;
emit(const BuyPaymentInfoInitial());
}

Future<BuyPaymentInfoState> _runGetPaymentInfo(String amount, Currency currency) async {
final int charged;
try {
charged = chargedFiatAmount(amount);
} on FormatException {
return const BuyPaymentInfoFailure(PaymentInfoError.invalidAmountFormat);
}

try {
final paymentInfo = await _buyPaymentInfoService.getPaymentInfo(
chargedFiatAmount(amount),
charged,
currency: currency,
);

Expand Down
3 changes: 2 additions & 1 deletion lib/screens/buy/widgets/payment_converter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import 'package:realunit_wallet/screens/buy/cubits/buy_converter/buy_converter_c
import 'package:realunit_wallet/setup/di.dart';
import 'package:realunit_wallet/styles/colors.dart';
import 'package:realunit_wallet/styles/currency.dart';
import 'package:realunit_wallet/widgets/fiat_input_formatter.dart';

class PaymentConverter extends StatefulWidget {
const PaymentConverter({
Expand Down Expand Up @@ -100,7 +101,7 @@ class _PaymentConverterState extends State<PaymentConverter> {
child: TextField(
controller: _amountController,
keyboardType: const .numberWithOptions(decimal: true),
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'[0-9.,]'))],
inputFormatters: const [FiatInputFormatter()],
decoration: const InputDecoration(
border: .none,
contentPadding: .symmetric(
Expand Down
5 changes: 5 additions & 0 deletions lib/screens/buy/widgets/payment_information.dart
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ class PaymentInformation extends StatelessWidget {
title: S.of(context).bitboxDisconnectedTitle,
description: S.of(context).bitboxDisconnectedDescription,
);
} else if (error == PaymentInfoError.invalidAmountFormat) {
return PaymentActionRequired(
title: S.of(context).invalidAmountFormatTitle,
description: S.of(context).invalidAmountFormatDescription,
);
} else if (error == PaymentInfoError.priceSourceUnavailable ||
error == PaymentInfoError.unknown) {
if (paymentInfoState.message.isEmpty) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ class SellConverterCubit extends Cubit<SellConverterState> {
} catch (e) {
developer.log(e.toString());
if (isClosed || mySeq != _seq) return;
emit(state.copyWith(loading: false));
emit(state.copyWith(loading: false, sharesText: ''));
}
});
}
Expand All @@ -75,7 +75,7 @@ class SellConverterCubit extends Cubit<SellConverterState> {
} catch (e) {
developer.log(e.toString());
if (isClosed || mySeq != _seq) return;
emit(state.copyWith(loading: false));
emit(state.copyWith(loading: false, fiatText: ''));
}
});
}
Expand All @@ -99,7 +99,7 @@ class SellConverterCubit extends Cubit<SellConverterState> {
} catch (e) {
developer.log(e.toString());
if (isClosed || mySeq != _seq) return;
emit(state.copyWith(loading: false));
emit(state.copyWith(loading: false, fiatText: ''));
}
}

Expand Down
3 changes: 2 additions & 1 deletion lib/screens/sell/widgets/sell_converter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import 'package:realunit_wallet/screens/sell/widgets/sell_max_amount_button.dart
import 'package:realunit_wallet/setup/di.dart';
import 'package:realunit_wallet/styles/colors.dart';
import 'package:realunit_wallet/styles/currency.dart';
import 'package:realunit_wallet/widgets/fiat_input_formatter.dart';

class SellConverter extends StatefulWidget {
const SellConverter({
Expand Down Expand Up @@ -222,7 +223,7 @@ class _SellConverterState extends State<SellConverter> {
child: TextField(
controller: _resultController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'[0-9.,]'))],
inputFormatters: const [FiatInputFormatter()],
decoration: const InputDecoration(
border: InputBorder.none,
contentPadding: EdgeInsets.symmetric(
Expand Down
36 changes: 36 additions & 0 deletions lib/widgets/fiat_input_formatter.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import 'package:flutter/services.dart';
import 'package:realunit_wallet/packages/utils/fiat_amount.dart';

/// Digits and separators only. A completed thousands group (`1.000` /
/// `1,000` / `1.000.000`) is rewritten to the integer; mixed thousands plus
/// decimal (`1.000,50` / `1,000.50`) keep the decimal; partials (`1.`,
/// `1.0`, `1.00`) stay as typed.
class FiatInputFormatter extends TextInputFormatter {
const FiatInputFormatter();

static final _allowedChars = FilteringTextInputFormatter.allow(RegExp(r'[0-9.,]'));

@override
TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) {
final allowed = _allowedChars.formatEditUpdate(oldValue, newValue);
final separator = strippedFiatGroupingSeparator(allowed.text);
final normalized = normalizeFiatInput(allowed.text);
if (normalized == allowed.text) return allowed;
final cursor = allowed.selection.baseOffset.clamp(0, allowed.text.length);
// Only separators actually dropped that sit before the caret. A global
// length delta also subtracts separators behind it (typing `1` into
// `.000,50` would move the caret to 0 instead of 1).
var removedBefore = 0;
if (separator != null) {
for (var i = 0; i < cursor; i++) {
if (allowed.text[i] == separator) removedBefore++;
}
}
return TextEditingValue(
text: normalized,
selection: TextSelection.collapsed(
offset: (cursor - removedBefore).clamp(0, normalized.length),
),
);
}
}
25 changes: 25 additions & 0 deletions test/goldens/screens/buy/buy_golden_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,31 @@ void main() {
},
);

goldenTest(
'invalid amount format failure',
fileName: 'buy_invalid_amount_format',
constraints: const BoxConstraints.tightFor(width: 390, height: 844),
builder: () {
when(() => paymentInfoCubit.state).thenReturn(
const BuyPaymentInfoFailure(PaymentInfoError.invalidAmountFormat),
);
when(() => converterCubit.state).thenReturn(
const BuyConverterState(
// This golden stubs the failure panel and does not run the
// formatter. Same-separator thousands groups and mixed
// thousands + 1–2 decimal digits of the other separator are
// stripped before the parser; a 3-digit mixed tail is not a
// unique thousands-then-decimal, so `1.000,000` is left as
// typed, reaches chargedFiatAmount, and throws FormatException.
fiatText: '1.000,000',
sharesText: '',
currency: Currency.chf,
),
);
return wrapForGolden(buildSubject());
},
);

goldenTest(
'price source unavailable failure shows API message',
fileName: 'buy_price_source_unavailable',
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -182,9 +182,9 @@ void main() {
});

group('$PaymentInfoError', () {
test('has the eight documented variants', () {
test('has the nine documented variants', () {
// Pin the wire contract — any new variant has to be added intentionally.
expect(PaymentInfoError.values, hasLength(8));
expect(PaymentInfoError.values, hasLength(9));
expect(
PaymentInfoError.values.toSet(),
{
Expand All @@ -196,6 +196,7 @@ void main() {
PaymentInfoError.bitboxDisconnected,
PaymentInfoError.priceSourceUnavailable,
PaymentInfoError.unknown,
PaymentInfoError.invalidAmountFormat,
},
);
});
Expand Down
47 changes: 47 additions & 0 deletions test/packages/utils/fiat_amount_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ void main() {

test('returns null on multi-separator input', () {
expect(tryParseFiatAmount('1.300,75'), isNull);
// Consecutive thousands groups are stripped by [normalizeFiatInput]
// before the parser sees them. The parser itself still rejects the
// raw grouped form.
expect(tryParseFiatAmount('1.000.000'), isNull);
});

test('returns null on grouping-ambiguous input (separator + 3 digits)', () {
Expand All @@ -59,4 +63,47 @@ void main() {
expect(tryParseFiatAmount('1,50'), 1.5);
});
});

group('normalizeFiatInput', () {
test('strips a lone thousands group', () {
expect(normalizeFiatInput('1.000'), '1000');
expect(normalizeFiatInput('1,000'), '1000');
expect(normalizeFiatInput('10.000'), '10000');
expect(normalizeFiatInput('10,000'), '10000');
});

test('strips consecutive thousands groups with the same separator', () {
expect(normalizeFiatInput('1.000.000'), '1000000');
expect(normalizeFiatInput('1,000,000'), '1000000');
expect(normalizeFiatInput('10.000.000'), '10000000');
expect(normalizeFiatInput('1000.000.000'), '1000000000');
});

test('strips mixed thousands grouping plus a decimal of the other separator', () {
expect(normalizeFiatInput('1.000,50'), '1000,50');
expect(normalizeFiatInput('1,000.50'), '1000.50');
expect(normalizeFiatInput('1.000,5'), '1000,5');
expect(normalizeFiatInput('1,000.5'), '1000.5');
expect(normalizeFiatInput('1.300,75'), '1300,75');
expect(normalizeFiatInput('1.000.000,50'), '1000000,50');
expect(normalizeFiatInput('1,000,000.50'), '1000000.50');
});

test('leaves every other input unchanged', () {
expect(normalizeFiatInput('300'), '300');
expect(normalizeFiatInput('300,75'), '300,75');
expect(normalizeFiatInput('0,5'), '0,5');
expect(normalizeFiatInput('1.000,000'), '1.000,000');
expect(normalizeFiatInput('1,000.000'), '1,000.000');
expect(normalizeFiatInput('1.000.50'), '1.000.50');
expect(normalizeFiatInput('1,000,50'), '1,000,50');
expect(normalizeFiatInput('3,5,7'), '3,5,7');
});

test('leaves partial thousands-group input unchanged', () {
expect(normalizeFiatInput('1.'), '1.');
expect(normalizeFiatInput('1.0'), '1.0');
expect(normalizeFiatInput('1.00'), '1.00');
});
});
}
Loading
Loading