Reviewed Flutter implementation · 2026-10-28
money_screen.dart
mobile_flutter/lib/features/money/presentation/money_screen.dart
Immutable source snapshot based on revision ce45c9c3bbde. Original line numbers are retained. This is implementation evidence; check the public API catalogue before adapting it.
The mobile /api/v1/mobile routes belong to the example backend. Company credentials stay on that backend. Some sections outside the documented product scope may be omitted.
Lines 1–3998
import '../../../shared/widgets/payee_name_check.dart';import '../../../shared/widgets/refresh_action.dart';import 'package:mobile_flutter/core/l10n/app_localizations.dart';import 'dart:async'; import 'package:dio/dio.dart';import 'package:flutter/material.dart';import 'package:flutter/foundation.dart';import 'package:flutter_riverpod/flutter_riverpod.dart';import 'package:go_router/go_router.dart'; import '../../../app/routes.dart';import 'package:mobile_scanner/mobile_scanner.dart'; import '../../../brands/cardeo/cardeo.dart';import '../../../core/models/banking_models.dart';import '../../../core/models/platform_models.dart';import '../../../core/widgets/app_states.dart';import '../../../core/branding/app_design.dart';import '../../../shared/shared.dart';import '../../auth/presentation/cardeo_otp_field.dart';import '../../banking/application/banking_providers.dart';import '../../platform/application/platform_providers.dart';import '../../platform/presentation/onboarding_banking_screen.dart';import '../../wallets/domain/wallet_models.dart';import '../../wallets/presentation/wallets_screen.dart';import '../domain/payment_balance_options.dart';import 'accounts_hub_header.dart';import '../../wallets/presentation/exchange_panel.dart';import '../domain/bank_payee_draft.dart';import 'payee_country_field.dart';import '../domain/balance_value_order.dart'; enum MoneyTab { accounts, pay, payees } class MoneyScreen extends ConsumerStatefulWidget { const MoneyScreen({ this.initialTab = MoneyTab.accounts, this.initialBudgetId = '', this.initialCurrency = '', this.initialPayee, super.key, }); final BankPayeeDraft? initialPayee; final MoneyTab initialTab; final String initialBudgetId; final String initialCurrency; @override ConsumerState<MoneyScreen> createState() => _MoneyScreenState();} class _MoneyScreenState extends ConsumerState<MoneyScreen> with SingleTickerProviderStateMixin { late final TabController _tabController; @override void initState() { super.initState(); _tabController = TabController( length: 3, initialIndex: widget.initialTab.index, vsync: this, )..addListener(_handleTabChange); } void _handleTabChange() { if (!_tabController.indexIsChanging) { setState(() {}); } } @override void dispose() { _tabController ..removeListener(_handleTabChange) ..dispose(); super.dispose(); } @override Widget build(BuildContext context) { final dashboard = ref.watch(dashboardProvider); final budgets = ref.watch(budgetsProvider); final kycStatus = ref.watch(kycDetailedStatusProvider); final tenantConfig = ref.watch(mobileTenantConfigProvider); final platformAction = ref.watch(platformActionControllerProvider); final valuationRates = ref.watch(portfolioEstimateProvider).valueOrNull?.valuationRates ?? const <String, double>{}; // Crypto-only installations have no fiat accounts: the Accounts tab is // the wallet view, so any deep link here goes there instead of the bank // onboarding gate. if (tenantConfig.valueOrNull?.equalsMoneyEnabled == false) { WidgetsBinding.instance.addPostFrameCallback((_) { if (context.mounted) context.go(AppRoutes.walletAssets); }); return const Scaffold(body: SizedBox.shrink()); } final disclosure = resolveEqualsPaymentServicesDisclosure( localeCountryCode: WidgetsBinding.instance.platformDispatcher.locale.countryCode ?? '', defaultRegion: tenantConfig.valueOrNull?.equalsRegulatoryRegionDefault ?? 'EU', euDisclosure: tenantConfig.valueOrNull?.equalsRegulatoryDisclaimerEu, ukDisclosure: tenantConfig.valueOrNull?.equalsRegulatoryDisclaimerUk, ); ref.listen(bankingActionControllerProvider, (previous, next) { // Ignore the notifier's initial build; only react to real actions. if (previous?.isLoading != true || previous?.hasValue != true) { return; } next.whenOrNull( data: (_) => ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.tr('Request submitted'))), ), error: (error, stackTrace) => ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(friendlyErrorMessage(error))), ), ); }); final screen = Scaffold( body: kycStatus.when( data: (detailedStatus) { final personal = dashboard.valueOrNull?.profile.isBusinessAccount != true; // Money needs Equals Money onboarding, but the crypto side does not: // keep the Money / Crypto switch on top so Interlace customers can // still reach their wallets. final gateHeader = AccountsHubHeader( selected: AccountsHubSection.money, showMoney: tenantConfig.valueOrNull?.equalsMoneyEnabled ?? true, showCrypto: personal, showExchange: tenantConfig.valueOrNull?.boomFiExchangeEnabled == true, ); if (detailedStatus.requiresEqualsMoneyAction) { return _EqualsMoneyActionRequired( status: detailedStatus, header: gateHeader, ); } return dashboard.when( data: (snapshot) { if (!snapshot.canUseBanking) { return _BankingRequired( onContinue: () => context.go('/onboarding/banking'), header: gateHeader, ); } final isBusinessAccount = snapshot.profile.isBusinessAccount; final exchangeEnabled = tenantConfig.valueOrNull?.boomFiExchangeEnabled == true; final isCardeo = context.isCardeoTheme; final width = MediaQuery.sizeOf(context).width; final desktop = isCardeo && width >= (kIsWeb ? 600 : 760); return Scaffold( appBar: AppBar( title: Text(context.tr('Accounts')), actions: [ RefreshAction(onRefresh: () async { ref.invalidate(budgetsProvider); ref.invalidate(accountsProvider); ref.invalidate(equalsBankingInfoProvider); ref.invalidate(payeesProvider); await Future.wait([ ref.read(budgetsProvider.future), ref.read(accountsProvider.future), ref.read(payeesProvider.future), ]); }), if (!desktop) ...[ IconButton( tooltip: context.tr('Settings'), onPressed: () => context.go('/profile'), icon: const Icon(Icons.settings_outlined), ), const SizedBox(width: AppSpacing.xs), ], ], ), body: Column( children: [ Padding( padding: const EdgeInsets.fromLTRB(20, 8, 20, 0), child: AccountsHubHeader( selected: AccountsHubSection.money, showMoney: tenantConfig.valueOrNull?.equalsMoneyEnabled ?? true, showCrypto: !isBusinessAccount, showExchange: exchangeEnabled, transferActions: exchangeEnabled && !isBusinessAccount ? ExchangeAccountTransferActions( onConvertToEur: (budget) => showEqualsMoneyConversionDialog( context, ref, [budget], targetCurrency: 'EUR')) : null, ), ), SizedBox(height: isCardeo ? 14 : 18), if (isCardeo && widget.initialTab == MoneyTab.accounts) ...[ Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: _CardeoMoneyOverview( totalBalance: snapshot.totalBalance, balances: _equalsOverviewBalances( snapshot, budgets.valueOrNull ?? const [], valuationRates, ), canConvert: canConvertEqualsMoneyBudgets( budgets.valueOrNull ?? const [], ), onAddMoney: () => showEqualsMoneyAddMoneyDialog(context), onConvert: () => showEqualsMoneyConversionDialog( context, ref, budgets.valueOrNull ?? const [], ), onCreateBudget: platformAction.isLoading ? null : () => showCreateEqualsBudgetDialog( context, ref, ), ), ), const SizedBox(height: 12), // The safeguarding note shares the heading's line: it // is a footnote to the balances above, not a section. Padding( padding: const EdgeInsets.symmetric(horizontal: 20), // Weighted so the note keeps its natural width at // 375 and only starts to ellipsise past a 1.3 text // scale, while the heading can never be pushed off // the line. child: Row( children: [ Expanded( flex: 2, child: _MoneySectionHeadline( title: context.tr('My accounts')), ), const SizedBox(width: AppSpacing.sm), const Flexible( flex: 3, child: SafeguardingStatementButton(), ), ], ), ), ] else ...[ Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: _EqualsMoneyHeader( disclosure: disclosure, isCreating: platformAction.isLoading, canConvert: canConvertEqualsMoneyBudgets( budgets.valueOrNull ?? const [], ), onConvert: () => showEqualsMoneyConversionDialog( context, ref, budgets.valueOrNull ?? const [], ), onCreateBudget: () => showCreateEqualsBudgetDialog(context, ref), ), ), EqualsMoneyTabBar(controller: _tabController), ], Expanded( child: isCardeo && widget.initialTab == MoneyTab.accounts ? WalletsScreen( initialView: WalletView.balances, embedded: true, initialBudgetId: widget.initialBudgetId, initialCurrency: widget.initialCurrency, ) : TabBarView( controller: _tabController, children: [ WalletsScreen( initialView: WalletView.balances, embedded: true, initialBudgetId: widget.initialTab == MoneyTab.accounts ? widget.initialBudgetId : '', initialCurrency: widget.initialTab == MoneyTab.accounts ? widget.initialCurrency : '', ), _PaymentForm( initialAccountId: widget.initialBudgetId, ), _PayeesView(initialDraft: widget.initialPayee), ], ), ), ], ), ); }, error: (error, stackTrace) => _MoneyErrorState( error: error, onRetry: () => ref.invalidate(dashboardProvider), ), loading: () => LoadingState(label: context.tr('Loading accounts')), ); }, error: (error, stackTrace) => _MoneyErrorState( error: error, onRetry: () => ref.invalidate(kycDetailedStatusProvider), ), loading: () => LoadingState(label: context.tr('Checking onboarding status')), ), ); // The alive layer is one scope per screen. The banking shell provides it // on the tabbed routes; on the routes that mount this screen on its own // there is none, and the two allowed hosts here — the hero panel's top // hairline and the section headline — would fall back to their static // highlight. CardeoAliveLayer asks `existsAbove`, which is the only // correct question: `maybeOf` reports null for a scope that is switched // *off*, so asking it here would nest a fresh enabled scope under a // deliberately disabled one and defeat the kill switch. return CardeoAliveLayer(enabled: context.isCardeoTheme, child: screen); }} /// Funded balances shown in the Accounts overview: the dashboard accounts/// when they carry money, otherwise the Fiat account budget balances.List<Money> _equalsOverviewBalances( DashboardSnapshot snapshot, List<PlatformResource> budgets, Map<String, double> valuationRates,) { // Budgets are the source of truth the Accounts list renders; their rows // are deduped because the API repeats a balance under several keys. final fromBudgets = equalsBudgetBalances(budgets, valuationRates: valuationRates); if (fromBudgets.isNotEmpty) return fromBudgets; final byCurrency = <String, int>{}; void add(Money money) { final code = money.currency.trim().toUpperCase(); if (code.isEmpty || money.minorUnits == 0) return; byCurrency.update(code, (value) => value + money.minorUnits, ifAbsent: () => money.minorUnits); } for (final account in snapshot.accounts) { if (account.provider != 'EqualsMoney') continue; if (account.currencyBalances.isEmpty) { add(account.balance); } else { final seen = <String>{}; for (final money in account.currencyBalances) { if (seen.add('${money.currency}:${money.minorUnits}')) add(money); } } } if (byCurrency.isEmpty) { for (final budget in budgets) { for (final key in const ['balances', 'Balances', 'currencyBalances']) { final rows = budget.metadata[key]; if (rows is! List) continue; for (final row in rows.whereType<Map>()) { final currency = (row['currency'] ?? row['Currency'])?.toString(); final amount = row['available'] ?? row['Available'] ?? row['amount'] ?? row['Amount'] ?? row['balance'] ?? row['Balance']; if (currency == null || amount == null) continue; add(Money.fromJson({'currency': currency, 'amount': amount})); } } } } final ordered = byCurrency.entries.toList() ..sort((a, b) => compareBalanceValues( a.key, a.value / 100, b.key, b.value / 100, valuationRates)); return [ for (final entry in ordered) Money(currency: entry.key, minorUnits: entry.value), ];} /// Centres a Cardeo state composition and lets it scroll rather than overflow.////// A pane at 375 x 812 holds a glyph, a headline, a body and a button at a 1.0/// text scale and not at 1.3, so the composition scrolls inside the centre: it/// stays optically centred while it fits and stays reachable when it does not./// No padding of its own — the composition already carries 24 / 32 and caps its/// measure at 320.Widget _centredState(Widget child) => Center( child: SingleChildScrollView(child: child), ); /// Nothing-here state for this screen.////// The shared [EmptyState] paints a Twilight card under *any* Cardeo theme —/// `darkSurface`, an iris wash and an iris glyph, all named as dark literals —/// so on paper it lands as a near-black slab with a 2.7:1 glyph. Until that/// shared widget resolves per brightness, Cardeo goes through the vocabulary's/// own [CardeoEmptyState], whose ink is re-earned for daylight. Every other/// brand gets the shared composition unchanged.class _MoneyEmptyState extends StatelessWidget { const _MoneyEmptyState({ required this.title, required this.message, required this.icon, }); final String title; final String message; final IconData icon; @override Widget build(BuildContext context) => context.isCardeoTheme ? _centredState( CardeoEmptyState(icon: icon, title: title, body: message), ) : EmptyState(title: title, message: message, icon: icon);} /// Failure state for this screen, for the same reason as [_MoneyEmptyState]:/// the shared [ErrorState] renders through [EmptyState] and inherits its/// Twilight card. [CardeoErrorState] announces itself in a live region and/// takes the danger ink of the active theme.////// The gateway warm-up case is kept verbatim: 502/503/504 right after/// registration means the account is still being provisioned, not that/// anything is broken, and the customer is told to wait rather than to retry/// into the same wall.class _MoneyErrorState extends StatelessWidget { const _MoneyErrorState({required this.error, this.onRetry}); final Object error; final VoidCallback? onRetry; bool get _providerWarmingUp => error is DioException && const {502, 503, 504} .contains((error as DioException).response?.statusCode); @override Widget build(BuildContext context) { if (!context.isCardeoTheme) { return ErrorState(error: error, onRetry: onRetry); } if (_providerWarmingUp) { // Provisioning is a wait, not a failure: the empty composition tints its // glyph with the accent, where the error composition would put a danger // red on a screen where nothing has gone wrong. return _centredState( CardeoEmptyState( icon: Icons.hourglass_top_rounded, title: context.tr('Your account is being set up'), body: context.tr( 'This usually takes a minute right after registration. Please wait a moment and try again.'), actionLabel: onRetry == null ? null : context.tr('Try again'), onAction: onRetry, ), ); } return _centredState( CardeoErrorState(error: error, onRetry: onRetry), ); }} /// The Money hub hero: the one balance the screen is about, on a panel that/// carries its own light.////// Composition, all vocabulary. A bounded [CardeoAtmosphere] gives the panel a/// ground that is lit rather than filled — `CardeoSurface.of(context, 1)`/// (darkSurface at night, white on paper) under a violet dawn and a teal/// counter-glow stated per brightness — and the screen's single/// [CardeoSweepBorder] rims it. The balance is a [CardeoAmount], so the/// numerals are tabular, the cents are set at 60 percent and the ISO code is/// smaller and in the secondary ink.////// Cardeo-only by construction: the accounts branch of [MoneyScreen] is the/// only thing that builds it, and that branch is already behind `isCardeo`./// A brightness or brand gate inside here would be dead code.////// It reads top to bottom as four registers, and each one is a different/// weight on purpose — the panel's own complaint used to be that everything on/// it was the same weight except the number:////// 1. the masthead — the currency's flag artwork and an eyebrow label;/// 2. the balance, at [CardeoAmountSize.hero] or the largest volume that fits;/// 3. a hairline, then the [_MoneyLedger] — the other currencies as pockets/// at [CardeoAmountSize.small] instead of as one 12 px caption;/// 4. the actions, spent by role: one filled [CardeoGlassButton] and two matte/// chips, where there used to be three identical grey discs.////// This is still the screen's one moment: on arrival the numerals settle from/// the secondary ink to full ink over [CardeoMotion.state] through/// [CardeoStateSwitch], and only after the 420 ms route transition has/// finished. Nothing else animates, nothing moves, and under reduced motion/// the amount is simply at full ink on the first frame. The CTA below it is/// built with its endless sheen switched off for the same reason: one panel,/// one moving thing.////// The atmosphere is given an explicit [CardeoAtmosphere.base] and an explicit/// glow list per brightness rather than a preset: presets resolve daylight/// from `theme.scaffoldBackgroundColor`, which the banking shell overrides to/// transparent, so a preset inside the shell would paint Twilight on paper.class _CardeoMoneyOverview extends StatefulWidget { const _CardeoMoneyOverview({ required this.totalBalance, required this.canConvert, this.balances = const [], required this.onAddMoney, required this.onConvert, required this.onCreateBudget, }); final Money totalBalance; final List<Money> balances; final bool canConvert; final VoidCallback onAddMoney; final VoidCallback onConvert; final VoidCallback? onCreateBudget; @override State<_CardeoMoneyOverview> createState() => _CardeoMoneyOverviewState();} class _CardeoMoneyOverviewState extends State<_CardeoMoneyOverview> { /// Corner radius of the panel, the clip and the sweep, which must agree. static const double _radius = 22; /// Inner gutter of the panel. static const EdgeInsets _panelPadding = EdgeInsets.fromLTRB(18, 16, 18, 16); /// How long after mount the arrival moment is armed. Long enough that the /// route animation has been installed for real: a freshly pushed route /// reports its animation as completed on the first build. static const Duration _settleDelay = Duration(milliseconds: 240); /// Advance of one tabular numeral as a fraction of the font size, plus the /// two scales [CardeoAmount] sets the cents and the ISO code at. Together /// they estimate the rendered width of a balance so the panel can pick the /// largest volume it can actually hold: the amount steps down a size rather /// than fading out, because a faded balance is a truncated balance. static const double _glyphAdvance = .58; static const double _fractionScale = .6; static const double _codeScale = .42; bool _settled = false; Timer? _settleTimer; Animation<double>? _routeAnimation; @override void didChangeDependencies() { super.didChangeDependencies(); if (_settled) return; // Reduced motion has no arrival: the balance is correct on frame one. if (CardeoMotion.reduced(context)) { _settled = true; return; } _settleTimer ??= Timer(_settleDelay, _armFromRoute); } void _armFromRoute() { if (!mounted || _settled) return; final animation = ModalRoute.of(context)?.animation; if (animation == null || animation.status == AnimationStatus.completed) { setState(() => _settled = true); return; } _routeAnimation = animation..addStatusListener(_onRouteStatus); } void _onRouteStatus(AnimationStatus status) { if (status != AnimationStatus.completed) return; _detachRoute(); if (!mounted || _settled) return; setState(() => _settled = true); } void _detachRoute() { _routeAnimation?.removeStatusListener(_onRouteStatus); _routeAnimation = null; } @override void dispose() { _settleTimer?.cancel(); _detachRoute(); super.dispose(); } /// The panel's own lights at night: a violet dawn off the top-left corner /// with iris in the falloff, and the teal counter-glow low and right, so the /// hero has temperature contrast instead of one flat purple wash. static const List<CardeoAtmosphereGlow> _twilightGlows = [ CardeoAtmosphereGlow( color: CardeoColors.violet, center: Alignment(-.45, -1), radius: 1.15, alpha: .22, mid: CardeoColors.iris, midAlpha: .09, midStop: .45, ), CardeoAtmosphereGlow( color: CardeoColors.teal, center: Alignment(1.05, 1.1), radius: .85, alpha: .07, midStop: .45, ), ]; /// The same two lights on paper, at the daylight strength the presets use /// (about .4 of Twilight), stated outright because an explicit base turns /// the painter's own light branch off. static const List<CardeoAtmosphereGlow> _daylightGlows = [ CardeoAtmosphereGlow( color: CardeoColors.violet, center: Alignment(-.45, -1), radius: 1.15, alpha: .085, mid: CardeoColors.iris, midAlpha: .045, midStop: .45, ), CardeoAtmosphereGlow( color: CardeoColors.teal, center: Alignment(1.05, 1.1), radius: .85, alpha: .05, midStop: .45, ), ]; /// Estimated rendered width of [money] set at [size], run by run: the whole /// digits at full volume, the cents at 60 percent and the ISO code at the /// small size [CardeoAmount] gives it. static double _amountWidth( Money money, CardeoAmountSize size, double textScale, ) { final iso = money.currency.trim().toUpperCase(); var number = money.formatted; // `Money.formatAmount` appends " CODE" when it has no symbol for the // currency and `CardeoAmount` peels it off to set it small; measure the // same two runs rather than counting the code twice. if (iso.isNotEmpty && number.endsWith(' $iso')) { number = number.substring(0, number.length - iso.length - 1); } final split = size == CardeoAmountSize.hero || size == CardeoAmountSize.large; final dot = number.lastIndexOf('.'); final whole = split && dot > 0 ? number.substring(0, dot) : number; final fraction = split && dot > 0 ? number.substring(dot) : ''; final em = size.fontSize * textScale * _glyphAdvance; return whole.length * em + fraction.length * em * _fractionScale + (iso.isEmpty ? 0 : (iso.length + 1) * em * _codeScale); } /// Largest amount volume whose rendered string still fits [available]. static CardeoAmountSize _amountSize( Money money, double available, double textScale, ) { for (final size in const [ CardeoAmountSize.hero, CardeoAmountSize.large, ]) { if (_amountWidth(money, size, textScale) <= available) return size; } return CardeoAmountSize.medium; } /// Width the wide layout hands to the action column at a 1.0 text scale. /// The amount measures against what is left *after* this and its gutter, so /// the hero never steps down for space the actions were never going to use. static const double _actionsColumn = 220; /// Most of the panel the action column is allowed to take. Uncapped, the /// narrowest panel that takes the two-column layout — 500 px on web — would /// hand a 1.6 text scale a 352 px column and leave the balance beside it /// `500 - 36 - 352 - 16` = 96 px. Past this share the two columns have /// stopped being two columns, which is why it reads as the affordability /// test in [_isWide] rather than as a clamp on the column. static const double _actionsColumnMaxShare = .45; /// The column at [textScale]. /// /// The chip row splits this width between two chips — the [Row] in /// [_actions] is `Expanded`, `SizedBox(width: xs)`, `Expanded` — and each /// chip spends an 18 px icon and its 8 px gutter before the label, so a /// label's room is `(column - 8) / 2 - 26`, which at `220 * textScale` is /// `110 * textScale - 30`. "New budget" set at 13 px w600 is about 72 px at /// a 1.0 scale and grows with it, so the room grows by 110 per unit of /// scale against the label's 72 and the label fits at every accessibility /// scale — but only while the column really is `220 * textScale`. Held at /// a flat 220 the two grow at 0 against 72 and the longer label ellipsises /// from a little over 1.1 up, which is the clipping the narrow layout was /// restructured into two rows to avoid, reappearing on desktop. static double _actionsColumnFor(double textScale) => _actionsColumn * textScale; /// Whether a panel [available] px wide sets the summary and the actions /// side by side at [textScale]. /// /// Two conditions, and the second is why a scale can change the layout: the /// panel has to be wide enough for two columns at all, and it has to be /// able to hand the actions their whole [_actionsColumnFor] width inside /// [_actionsColumnMaxShare]. Clamping the column to the share instead was /// the shape this replaces, and it put the ellipsis back in a band: on web /// a 500 px panel at a 1.3 scale clamped 286 to 225, which is 82 px of /// label room against the 94 the label wants. The label clipped up to a /// 549 px panel and the clamp bit at all up to 636. Stacking hands /// `_actions()` the whole panel instead — at 500 px that is 464, so each /// chip is 228 and each label 202 — and the balance above it keeps the /// whole gutter rather than losing the column and its 16 px. static bool _isWide(double available, double textScale) => available >= (kIsWeb ? 500 : 760) && _actionsColumnFor(textScale) <= available * _actionsColumnMaxShare; @override Widget build(BuildContext context) { final balances = widget.balances; final primary = balances.isEmpty ? widget.totalBalance : balances.first; final rest = balances.length > 1 ? balances.sublist(1) : const <Money>[]; final textScale = MediaQuery.textScalerOf(context).scale(1) * context.brandDesign.typographyScale; return LayoutBuilder( builder: (context, constraints) { final wide = _isWide(constraints.maxWidth, textScale); final actionsColumn = _actionsColumnFor(textScale); // The amount never shares its line, so the whole panel gutter is its // measure; on the wide layout the actions column takes a slice first. final measure = constraints.maxWidth - _panelPadding.horizontal - (wide ? actionsColumn + AppSpacing.md : 0.0); final size = _amountSize(primary, measure, textScale); final summary = Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ // Masthead. The currency's own artwork comes first, so the hero // says *which* money this is before it says how much, and it says // it with the same disc the balances underneath use rather than // with a word. The label is set in the eyebrow voice instead of as // one more 12.5 px line, which is what gave the panel a type // ladder of 44 / 12.5 / 12 — three volumes that read as two. // // Silent to a screen reader, because it is a picture of what the // amount node below already says in full: "Money balance 7,294.75 // EUR". Announced, the masthead's own disc and eyebrow made that // three consecutive nodes — "EUR", "Money balance", "Money // balance 7,294.75 EUR" — the phrase twice over. One object, one // node; the pockets underneath fold their disc in the same way. // // "Add money" sits at the masthead's far end rather than as a // full-width bar under the balance: it is the reason the screen // exists, so it belongs beside the title, and hugging its label // it stops shouting over the figure it serves. It is outside the // [ExcludeSemantics] so it keeps its own node. On the wide layout // the masthead spans only the summary column, so the button moves // to the top of the action column instead — still the panel's // top-right corner, which is the one place it is meant to be. Row( children: [ Expanded( child: ExcludeSemantics( child: Row( children: [ CardeoCurrencyAvatar(code: primary.currency, size: 18), const SizedBox(width: AppSpacing.xs), Flexible( child: Text( context.tr('MONEY BALANCE'), maxLines: 1, overflow: TextOverflow.ellipsis, style: CardeoTextStyles.label(context), ), ), ], ), ), ), if (!wide) ...[ const SizedBox(width: AppSpacing.sm), _addMoney(), ], ], ), const SizedBox(height: AppSpacing.xs), _SettlingAmount( money: primary, size: size, settled: _settled, ), if (rest.isNotEmpty) ...[ const SizedBox(height: AppSpacing.sm), // A rule, not a second card. The ledger is the same object one // register down; a panel nested inside the hero would flatten // the hierarchy this exists to build, and the vocabulary's own // answer to "separate without nesting" is a hairline. SizedBox( width: double.infinity, height: 1, child: ColoredBox( color: CardeoBorders.hairlineSideOf(context).color, ), ), const SizedBox(height: AppSpacing.sm), _MoneyLedger(balances: rest), ], ], ); return _panel( context, wide ? Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded(child: summary), const SizedBox(width: AppSpacing.md), SizedBox( width: actionsColumn, child: _actions(wide: true), ), ], ) : Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ summary, const SizedBox(height: AppSpacing.md), _actions(wide: false), ], ), ); }, ); } /// The three things a customer can do here, spent by role instead of /// stamped three times. /// /// This used to be three identical [CardeoCircleAction] discs in a row, and /// the row could not answer either of the two questions a customer asks of /// it. *Which of these is the point?* — none of them, they were the same /// object at the same weight. *Which of these can I actually use?* — also /// none, because a disc at [CardeoOpacity.disabled] beside two discs that /// were already quiet grey circles reads as three shades of the same /// unavailable. /// /// So: "Add money" is the reason this screen exists and becomes the one /// lifted object on the panel — a filled [CardeoGlassButton], the house /// CTA, which no reader mistakes for disabled. It hugs its label now (see /// [_addMoney]) and sits at the panel's top-right: on the masthead beside /// the title when the panel stacks, at the head of this column when it /// does not. "Convert" and "New budget" /// are the same matte chip the Pay header uses, one surface step up from /// the panel ground so they read as controls resting on it. A disabled chip /// beside a filled pill is unmistakably disabled; a disabled disc beside two /// discs was not. /// /// Two chips on their own row rather than three labels on one: at 375 the /// three cannot share a line without ellipsising "New budget", and a CTA /// that has to be guessed at is not a CTA. /// /// `ground: atmosphere` is the literal truth — the button sits inside this /// panel's [CardeoAtmosphere], so its body samples the panel's own light /// instead of inventing a haze. `sheen: false` is deliberate: the panel /// already carries the screen's lit hairline, which is exactly the case /// [CardeoGlassButton.sheen] documents for turning it off. Widget _addMoney() => CardeoGlassButton( label: context.tr('Add money'), icon: Icons.add_rounded, ground: CardeoGlassGround.atmosphere, expand: false, height: 40, padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), sheen: false, onPressed: widget.onAddMoney, ); Widget _actions({required bool wide}) => Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: [ // On the wide layout the CTA heads the column; stacked, it is // already on the masthead above. if (wide) ...[ _addMoney(), const SizedBox(height: AppSpacing.xs), ], Row( children: [ Expanded( child: _CardeoHeaderAction( icon: Icons.swap_horiz_rounded, label: context.tr('Convert'), surfaceLevel: 2, onTap: widget.canConvert ? widget.onConvert : null, ), ), const SizedBox(width: AppSpacing.xs), Expanded( child: _CardeoHeaderAction( icon: Icons.savings_outlined, label: context.tr('New budget'), surfaceLevel: 2, onTap: widget.onCreateBudget, ), ), ], ), ], ); /// The lit ground, the rim and — when the shell's alive layer is present — /// the one lit hairline along the panel's top edge. Widget _panel(BuildContext context, Widget child) { const borderRadius = BorderRadius.all(Radius.circular(_radius)); final light = CardeoTheme.isLight(context); final hero = CardeoSweepBorder( radius: _radius, child: DecoratedBox( decoration: BoxDecoration( borderRadius: borderRadius, // Empty in Twilight, where the surface step is the separation. boxShadow: CardeoShadows.ambientOf(context), ), child: ClipRRect( borderRadius: borderRadius, child: CardeoAtmosphere( base: CardeoSurface.of(context, 1), glows: light ? _daylightGlows : _twilightGlows, child: Padding(padding: _panelPadding, child: child), ), ), ), ); // The sheen lives on the shell's single scope; mounted on its own the // panel renders exactly the same pixels without one. if (CardeoSheenScope.maybeOf(context) == null) return hero; final peak = _decorationColor( context, 'sheenPeak', light ? CardeoColors.lightIris : CardeoColors.lavender, ); return Stack( children: [ hero, Positioned( top: 0, left: _radius, right: _radius, height: 1, child: IgnorePointer( child: CardeoSheen( intensity: CardeoSheenIntensity.soft, child: DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( colors: [ peak.withValues(alpha: 0), peak.withValues(alpha: light ? .40 : .32), peak.withValues(alpha: 0), ], ), ), child: const SizedBox.expand(), ), ), ), ), ], ); }} /// The hero balance and its one moment.////// [CardeoAmount] does its own crossfade when the value changes, but the/// arrival is a change of ink at a constant value, so the switch is owned here/// and the amount is handed `animate: false` — one [CardeoStateSwitch], not/// two nested ones. The key carries both the settle flag and the value, so a/// balance that refreshes later still crossfades.class _SettlingAmount extends StatelessWidget { const _SettlingAmount({ required this.money, required this.size, required this.settled, }); final Money money; final CardeoAmountSize size; final bool settled; @override Widget build(BuildContext context) => CardeoStateSwitch( alignment: Alignment.centerLeft, child: KeyedSubtree( key: ValueKey<String>( '$settled|${money.currency}|${money.minorUnits}', ), child: CardeoAmount( amount: money.minorUnits / 100, currency: money.currency, size: size, code: CardeoAmountCode.always, animate: false, color: settled ? CardeoInk.primary(context) : CardeoInk.secondary(context), semanticsLabel: context.tr('Money balance {p0}', {'p0': money.formatted}), ), ), );} /// The currencies the hero does not promote, as objects rather than as a/// caption.////// This line was `4,562.35 USD 1,860.00 GBP` in 12 px secondary ink — the/// quietest thing on the panel, and the second most important. It is the/// answer to "how is my money split", which is the question a multi-currency/// account exists to raise, and it was set at the volume the design system/// reserves for a timestamp.////// So each currency becomes a *pocket*: its real flag artwork, then its/// balance at [CardeoAmountSize.small]. That is a 44 / 18 / 11 type ladder/// instead of 44 / 12.5 / 12, and it is identity encoded in form — a US flag/// and a UK flag are told apart at a glance and at any text scale, where two/// currency symbols in a run-on string are not.////// What it deliberately does **not** do is draw a share-of-total bar. Without/// an FX rate, `EUR 7,294` and `AED 10,000` cannot be compared, and a bar/// implying they can would be invented data. Magnitude is carried by the one/// honest ordering the data already has — the promoted balance at 44 px, the/// rest at 18 px in descending order — and by nothing else.class _MoneyLedger extends StatelessWidget { const _MoneyLedger({required this.balances}); final List<Money> balances; /// Past this the strip stops being a glance and starts being a list, so the /// remainder is counted rather than drawn. A [Wrap] means the drawn ones /// still flow to a second line at a large text scale instead of overflowing. static const int _maxPockets = 3; @override Widget build(BuildContext context) { final shown = balances.take(_maxPockets).toList(); final extra = balances.length - shown.length; return Wrap( spacing: AppSpacing.md, runSpacing: AppSpacing.xs, crossAxisAlignment: WrapCrossAlignment.center, children: [ for (final money in shown) _CurrencyPocket(money: money), if (extra > 0) Text( context.tr('+{p0} MORE', {'p0': extra}), maxLines: 1, // Spelled out for a screen reader: the caps are typography, not // an acronym, and "MORE" is short enough that some engines spell // it letter by letter. semanticsLabel: context.tr('{p0} more {p1}', {'p0': extra, 'p1': extra == 1 ? 'currency' : 'currencies'}), // The counter is a marker, not a balance, so it takes the eyebrow // voice the masthead adopted rather than a fourth volume. Set as // a raw 12.5 px `TextStyle` it put back the exact off-ladder // volume this panel was rebuilt to remove, and it carried no // `fontFamily`, so a tenant's APP_FONT_FAMILY silently skipped it. style: CardeoTextStyles.label(context) .copyWith(color: CardeoInk.tertiary(context)), ), ], ); }} /// One currency in the ledger: its flag, then its balance.////// [CardeoAmountCode.always], the same as the hero one register up — a choice/// between two working options, not the only one that names a pocket./// `Money.formatAmount` carries a symbol for exactly USD, EUR and GBP/// (`Money._fiatSymbols`), those three sit inside the ten codes/// [CardeoCurrencyAvatar] paints artwork for, and `auto` prints the ISO code/// wherever the formatter produced no symbol. So on an unmasked balance `auto`/// drops a code only where a flag has already replaced it, and a JPY or PLN/// pocket keeps its code either way.////// Where the two part company is Private Mode. `Money.formatAmount` returns/// dots, so `auto` finds no trailing code to peel and renders `••••` alone/// while `always` still reads `•••• JPY` — and on a masked strip the disc is/// the only other name a pocket carries on screen, which for a currency with/// no artwork is a 6 px glyph. What `always` costs is a repeated "USD" beside/// a US flag, and pockets wide enough that at 375 with balances this long the/// [Wrap] gives each of the three a line of its own.////// The figure is [Flexible] rather than capped at a constant. `CardeoAmount`/// fades what outgrows its box, so a fixed 148 px cap faded a seven-figure/// balance at an accessibility text scale while the panel around it still had/// room to give — the truncation the hero's own step-down one register up/// exists to refuse, on the same panel. Flexible inside a min-size [Row] lets/// a pocket keep its intrinsic width and shrink only against the [Wrap]'s/// real limit, which is the whole panel.class _CurrencyPocket extends StatelessWidget { const _CurrencyPocket({required this.money}); final Money money; @override Widget build(BuildContext context) => Semantics( label: context.tr( '{p0} balance {p1}', {'p0': money.currency, 'p1': money.formatted}), excludeSemantics: true, child: Row( mainAxisSize: MainAxisSize.min, children: [ CardeoCurrencyAvatar(code: money.currency, size: 20), const SizedBox(width: AppSpacing.xs), Flexible( child: CardeoAmount( amount: money.minorUnits / 100, currency: money.currency, size: CardeoAmountSize.small, code: CardeoAmountCode.always, animate: false, ), ), ], ), );} /// The screen's one headline. Under the shell's alive layer a soft band walks/// the glyphs on arrival and once a cadence after; the type never moves, and/// without a scope this is a plain [CardeoSectionTitle].class _MoneySectionHeadline extends StatelessWidget { const _MoneySectionHeadline({required this.title}); final String title; @override Widget build(BuildContext context) { final headline = CardeoSectionTitle(title: title); if (CardeoSheenScope.maybeOf(context) == null) return headline; return CardeoSheen.text( intensity: CardeoSheenIntensity.soft, child: headline, ); }} /// Header of the Pay and Payees tabs: which account is in play, the/// safeguarding note, and the two account-level actions.////// Cardeo reads it in its own register — the account name in the section/// voice, the safeguarding note as a 44 pt icon target with a real semantics/// label, and the two actions as a pair of equal-width matte chips on their/// own line, so neither label can ever be clipped by the other at a 1.3 text/// scale. Utility chrome: nothing here animates beyond the 120 ms press./// Every other brand keeps the Material composition byte for byte.class _EqualsMoneyHeader extends StatelessWidget { const _EqualsMoneyHeader({ required this.disclosure, required this.isCreating, required this.canConvert, required this.onConvert, required this.onCreateBudget, }); final String disclosure; final bool isCreating; final bool canConvert; final VoidCallback onConvert; final VoidCallback onCreateBudget; @override Widget build(BuildContext context) { if (context.isCardeoTheme) return _cardeo(context); final theme = Theme.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Flexible( child: Text( context.tr('Fiat account'), style: theme.textTheme.titleLarge, ), ), const SizedBox(width: 4), IconButton( tooltip: context.tr('Safeguarding statement'), visualDensity: VisualDensity.compact, padding: EdgeInsets.zero, constraints: const BoxConstraints.tightFor( width: 34, height: 34, ), onPressed: () => showPaymentServicesDisclosure( context, disclosure: disclosure, ), icon: const Icon(Icons.info_outline_rounded, size: 20), ), ], ), Row( children: [ Expanded( child: Text( context.tr('Accounts & budgets'), style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ), TextButton.icon( style: TextButton.styleFrom( foregroundColor: theme.colorScheme.secondary, padding: const EdgeInsets.symmetric(horizontal: 6), visualDensity: VisualDensity.compact, ), onPressed: canConvert ? onConvert : null, icon: const Icon(Icons.swap_horiz_rounded, size: 20), label: Text(context.tr('Convert')), ), TextButton.icon( style: TextButton.styleFrom( foregroundColor: theme.colorScheme.secondary, padding: const EdgeInsets.symmetric(horizontal: 6), visualDensity: VisualDensity.compact, ), onPressed: isCreating ? null : onCreateBudget, icon: const Icon(Icons.add_rounded, size: 20), label: Text(context.tr('Create budget')), ), ], ), ], ); } /// The same information in the Cardeo register. Widget _cardeo(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Text( context.tr('Fiat account'), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 19 * context.brandDesign.typographyScale, fontWeight: FontWeight.w700, color: CardeoInk.primary(context), ), ), ), _CardeoHeaderIcon( icon: Icons.info_outline_rounded, label: context.tr('Safeguarding statement'), onTap: () => showPaymentServicesDisclosure( context, disclosure: disclosure, ), ), ], ), Text( context.tr('Accounts & budgets'), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 12.5 * context.brandDesign.typographyScale, color: CardeoInk.secondary(context), ), ), const SizedBox(height: AppSpacing.sm), // Equal shares rather than natural widths: 'Create budget' is the // longest label on the row and would push 'Convert' off the line at a // 1.3 text scale if either child sized itself. Row( children: [ Expanded( child: _CardeoHeaderAction( icon: Icons.swap_horiz_rounded, label: context.tr('Convert'), onTap: canConvert ? onConvert : null, ), ), const SizedBox(width: AppSpacing.sm), Expanded( child: _CardeoHeaderAction( icon: Icons.add_rounded, label: context.tr('Create budget'), onTap: isCreating ? null : onCreateBudget, ), ), ], ), ], ); }} /// Icon-only 44 pt target in the Cardeo header: no chrome, a real name for/// screen readers, and the 120 ms press every Cardeo control shares.class _CardeoHeaderIcon extends StatelessWidget { const _CardeoHeaderIcon({ required this.icon, required this.label, required this.onTap, }); final IconData icon; final String label; final VoidCallback? onTap; @override Widget build(BuildContext context) => Tooltip( message: label, excludeFromSemantics: true, child: CardeoPressable( onTap: onTap, semanticsLabel: label, borderRadius: BorderRadius.circular(AppRadii.pill), child: SizedBox( width: 44, height: 44, child: Icon( icon, size: 20, color: CardeoInk.secondary(context), ), ), ), );} /// Secondary action in the Cardeo header and in the accounts hero: a 44 pt/// matte chip, edged with the hairline of the active theme and — on paper,/// where a white chip on paper has almost no tonal contrast left — seated on/// the night ambient. The label ellipsizes rather than overflowing, and the/// whole chip dims to [CardeoOpacity.disabled] when there is nothing to do.class _CardeoHeaderAction extends StatelessWidget { const _CardeoHeaderAction({ required this.icon, required this.label, required this.onTap, this.surfaceLevel = 1, }); final IconData icon; final String label; final VoidCallback? onTap; /// Which step of the surface ladder the chip is drawn on. /// /// One is right for a chip resting on the page, which is where the Pay /// header puts it, and is the default so that call site is unchanged. A /// chip *inside* a level-1 panel takes two: at level 1 it would be the exact /// colour of the thing it is sitting on in Twilight, and only its hairline /// would prove it existed. final int surfaceLevel; @override Widget build(BuildContext context) { const radius = BorderRadius.all(Radius.circular(AppRadii.md)); final enabled = onTap != null; return Opacity( opacity: enabled ? 1 : CardeoOpacity.disabled, child: CardeoPressable( onTap: onTap, enabled: enabled, semanticsLabel: label, borderRadius: radius, child: DecoratedBox( decoration: BoxDecoration( color: CardeoSurface.of(context, surfaceLevel), borderRadius: radius, border: Border.fromBorderSide(CardeoBorders.sideOf(context)), // Empty in Twilight, where the surface step is the separation. boxShadow: CardeoShadows.ambientOf(context), ), child: SizedBox( height: 44, child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( icon, size: 18, color: CardeoInk.accent(context, CardeoColors.iris), ), const SizedBox(width: AppSpacing.xs), Flexible( child: Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 13 * context.brandDesign.typographyScale, fontWeight: FontWeight.w600, color: CardeoInk.primary(context), ), ), ), ], ), ), ), ), ); }} /// Accounts / Pay / Payees.////// Cardeo gets the pill vocabulary the accounts hub above it already uses, so/// the two control bars on the Pay and Payees tabs read as one system instead/// of a Cardeo pill over a Material tab strip. Every other brand keeps the/// Material [TabBar] byte for byte, including its indicator and label colours.class EqualsMoneyTabBar extends StatelessWidget { const EqualsMoneyTabBar({this.controller, super.key}); final TabController? controller; @override Widget build(BuildContext context) { final resolved = controller ?? DefaultTabController.maybeOf(context); if (context.isCardeoTheme && resolved != null) { return Padding( // 20 to sit on the same gutter as the accounts hub above it. padding: const EdgeInsets.fromLTRB(20, 4, 20, 12), child: _CardeoMoneyTabs(controller: resolved), ); } final colorScheme = Theme.of(context).colorScheme; return Padding( padding: const EdgeInsets.fromLTRB(16, 4, 16, 12), child: DecoratedBox( decoration: BoxDecoration( color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(18), ), child: TabBar( controller: controller, dividerColor: Colors.transparent, indicatorSize: TabBarIndicatorSize.tab, indicator: BoxDecoration( color: colorScheme.secondary, borderRadius: BorderRadius.circular(16), ), labelColor: colorScheme.onSecondary, unselectedLabelColor: colorScheme.onSurfaceVariant, labelStyle: const TextStyle(fontWeight: FontWeight.w700), tabs: [ _CompactMoneyTab( icon: Icons.account_balance_outlined, label: context.tr('Accounts'), ), _CompactMoneyTab( icon: Icons.receipt_long_outlined, label: context.tr('Pay')), _CompactMoneyTab( icon: Icons.people_outline, label: context.tr('Payees')), ], ), ), ); }} /// The Money tabs in the Cardeo register.////// One [CardeoSegmentedControl] driven by the screen's own [TabController], so/// a tap on a segment and a swipe of the [TabBarView] stay in step. The/// selection follows `index`, which the controller publishes the instant a tap/// lands, rather than the animation value — the segment moves on its own/// 200 ms state token instead of tracking a drag frame by frame. Utility/// chrome: there is no arrival moment here, and the one state move collapses/// to instant under reduced motion inside the control.class _CardeoMoneyTabs extends StatelessWidget { const _CardeoMoneyTabs({required this.controller}); final TabController controller; static const List<({int value, String label})> _segments = [ (value: 0, label: 'Accounts'), (value: 1, label: 'Pay'), (value: 2, label: 'Payees'), ]; @override Widget build(BuildContext context) => ListenableBuilder( listenable: controller, builder: (context, _) => CardeoSegmentedControl<int>( height: 44, segments: [ for (final segment in _segments) (value: segment.value, label: context.tr(segment.label)) ], selected: controller.index, onChanged: (index) { if (index != controller.index) controller.animateTo(index); }, ), );} class _CompactMoneyTab extends StatelessWidget { const _CompactMoneyTab({required this.icon, required this.label}); final IconData icon; final String label; @override Widget build(BuildContext context) => Tab( height: 48, child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(icon, size: 20), const SizedBox(width: 5), Flexible( child: Text( label, maxLines: 1, overflow: TextOverflow.fade, softWrap: false, ), ), ], ), );} class _EqualsMoneyActionRequired extends StatelessWidget { const _EqualsMoneyActionRequired({required this.status, this.header}); final KycDetailedStatus status; final Widget? header; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text(context.tr('Accounts'))), body: ListView( padding: const EdgeInsets.all(16), children: [ if (header != null) ...[ Padding( padding: const EdgeInsets.fromLTRB(4, 0, 4, 14), child: header, ), ], EqualsMoneyRequiredActionCard(status: status), ], ), ); }} class _BankingRequired extends StatelessWidget { const _BankingRequired({required this.onContinue, this.header}); final VoidCallback onContinue; final Widget? header; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text(context.tr('Accounts'))), body: Column( children: [ if (header != null) Padding( padding: const EdgeInsets.fromLTRB(20, 8, 20, 0), child: header, ), Expanded(child: _bankingPrompt(context)), ], ), ); } Widget _bankingPrompt(BuildContext context) { if (context.isCardeoTheme) { return _centredState( CardeoEmptyState( icon: Icons.account_balance_outlined, title: context.tr('Finish bank onboarding'), body: context.tr( 'Accounts and payments open as soon as your bank onboarding is complete.'), actionLabel: context.tr('Continue onboarding'), onAction: onContinue, ), ); } return Center( child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.account_balance, size: 48), const SizedBox(height: 12), Text( context.tr( 'Complete bank onboarding before viewing accounts and moving money.'), style: Theme.of(context).textTheme.titleMedium, textAlign: TextAlign.center, ), const SizedBox(height: 16), FilledButton.icon( onPressed: onContinue, icon: const Icon(Icons.account_balance), label: Text(context.tr('Continue onboarding')), ), ], ), ), ); }} class _PaymentForm extends ConsumerStatefulWidget { const _PaymentForm({this.initialAccountId = ''}); final String initialAccountId; @override ConsumerState<_PaymentForm> createState() => _PaymentFormState();} class _PaymentFormState extends ConsumerState<_PaymentForm> { final _formKey = GlobalKey<FormState>(); final _amountController = TextEditingController(); final _referenceController = TextEditingController(); final _otpController = TextEditingController(); String? _accountKey; String? _payeeKey; String? _currency; PayoutQuote? _quote; PayoutCheck? _check; String _verificationToken = ''; String _errorMessage = ''; bool _loading = false; Map<String, dynamic>? _paymentSummary; bool _otpSent = false; Timer? _quoteRefreshTimer; @override void dispose() { _quoteRefreshTimer?.cancel(); _amountController.dispose(); _referenceController.dispose(); _otpController.dispose(); super.dispose(); } /// The commit label for the step the flow is on. One button, three states: /// the target never moves under the thumb between quoting and confirming. String get _ctaLabel => _otpSent ? 'Confirm payment' : _quote == null ? 'Show fee' : 'Send OTP code'; /// The commit action for the step the flow is on. Null while a request is /// in flight, or while the amount and reference cannot yet buy a quote — /// the button then renders as an inert surface step rather than a live /// affordance the user can hammer. VoidCallback? _ctaAction( Payee payee, AccountBalance account, { required bool canRequestQuote, }) { if (_loading) return null; if (_otpSent) return _confirmPayout; if (_quote == null) { return canRequestQuote ? () => _requestQuote(payee, account) : null; } return () => _sendOtp(payee, account); } @override Widget build(BuildContext context) { final accounts = ref.watch(accountsProvider); final payees = ref.watch(payeesProvider); final budgets = ref.watch(budgetsProvider).valueOrNull ?? const []; return accounts.when( data: (accountItems) => payees.when( data: (payeeItems) { final sendablePayees = payeeItems.where((payee) => !payee.requiresConfirmation).toList(); final fundedAccounts = paymentAccountsWithBudgets( accountItems, budgets, ).where(hasFundedBalance).toList(); if (fundedAccounts.isEmpty) { return _MoneyEmptyState( title: context.tr('No funded balance'), message: context .tr('Add funds to an account before making a payment.'), icon: Icons.account_balance_wallet_outlined, ); } if (sendablePayees.isEmpty) { return _MoneyEmptyState( title: context.tr('No confirmed payees'), message: context .tr('Add or confirm a payee before sending a payment.'), icon: Icons.people_outline, ); } final payeeOptions = _payeeOptions(sendablePayees); _payeeKey ??= payeeOptions.firstOrNull?.key; final selectedPayeeEntry = _selectedPayeeEntry(payeeOptions); final selectedPayee = selectedPayeeEntry.payee; final accountOptions = _accountOptions(fundedAccounts); if (_accountKey == null && widget.initialAccountId.isNotEmpty) { _accountKey = accountOptions .where((option) => option.account.id == widget.initialAccountId) .firstOrNull ?.key; } if (!accountOptions.any((option) => option.key == _accountKey)) { _accountKey = accountOptions.first.key; } final selectedAccountEntry = _selectedAccountEntry(accountOptions); final selectedAccount = selectedAccountEntry.account; final currencyOptions = fundedCurrencyOptions( selectedAccount, preferredCurrency: selectedPayee.currency, ); if (_currency == null || !currencyOptions.contains(_currency)) { _currency = currencyOptions.first; } final targetCurrency = fallbackText(selectedPayee.currency, _currency!); final canRequestQuote = _amountValue() > 0 && _referenceController.text.trim().isNotEmpty; final theme = Theme.of(context); final isCardeo = context.isCardeoTheme; return LayoutBuilder( builder: (context, constraints) { final width = constraints.maxWidth; // Two desktop tiers, both from the existing ramp. This screen is // where a person commits money, so it is the one line on the // money surface that earns display type: it stops and breathes // before the form asks for a number. 44 at tablet width, 64 once // there is a real desktop page around it — measured against the // field, whose send flows head at 36 (RedotPay) to 80+ (ether.fi // Cash, Revolut). Below 834 nothing changes: mobile keeps the // 22 px headline and no line reflows at 375 or 393. final headline = !isCardeo || width < 834 ? theme.textTheme.headlineSmall ?.copyWith(fontWeight: FontWeight.w800) : width >= 1200 ? AppTypography.displayXl(theme.textTheme) : theme.textTheme.displayLarge; // A payment form stretched across 1440 px reads as a spreadsheet // and drags the eye across a 1300 px line per field. The laws cap // a form column at 520; the page keeps the rest as air. final gutter = isCardeo && width >= 834 ? ((width - 520) / 2).clamp(16.0, double.infinity) : 16.0; return Form( key: _formKey, child: ListView( padding: EdgeInsets.fromLTRB(gutter, 16, gutter, 16), children: [ Text( context.tr('Send money'), style: headline, ), const SizedBox(height: 4), Text( context.tr( 'Choose the account and recipient, then review the fee before confirming.'), style: theme.textTheme.bodyMedium, ), const SizedBox(height: 20), DropdownButtonFormField<String>( initialValue: selectedAccountEntry.key, decoration: InputDecoration( labelText: context.tr('From account')), items: [ for (final option in accountOptions) DropdownMenuItem( value: option.key, child: Text( paymentAccountOptionLabel(option.account), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), ], validator: _required, onChanged: (value) { setState(() { _accountKey = value; _currency = null; _resetPayoutState(); }); }, ), const SizedBox(height: 12), DropdownButtonFormField<String>( initialValue: selectedPayeeEntry.key, decoration: InputDecoration(labelText: context.tr('Payee')), items: [ for (final option in payeeOptions) DropdownMenuItem( value: option.key, child: Text(_payeeLabel(option.payee)), ), ], validator: _required, onChanged: (value) { setState(() { _payeeKey = value; _currency = null; _resetPayoutState(); }); }, ), const SizedBox(height: 12), DropdownButtonFormField<String>( initialValue: _currency, decoration: InputDecoration( labelText: context.tr('Pay from currency')), items: [ for (final currency in currencyOptions) DropdownMenuItem( value: currency, child: Text(currency), ), ], validator: _required, onChanged: (value) { setState(() { _currency = value; _resetPayoutState(); }); }, ), const SizedBox(height: 12), _AvailableBalanceHint( account: selectedAccount, currency: _currency!, ), const SafeguardingStatementButton(), const SizedBox(height: 12), _AmountField( controller: _amountController, currency: _currency!, onChanged: (_) => setState(() => _resetPayoutState()), ), const SizedBox(height: 12), TextFormField( controller: _referenceController, decoration: InputDecoration(labelText: context.tr('Reference')), validator: _required, onChanged: (_) => setState(_resetPayoutState), ), if (targetCurrency != _currency) ...[ const SizedBox(height: 8), Text( context.tr( 'Recipient currency: {p0}', {'p0': targetCurrency}), style: Theme.of(context).textTheme.bodySmall, ), ], const SizedBox(height: 16), if (_errorMessage.isNotEmpty) Card( color: Theme.of(context).colorScheme.errorContainer, child: Padding( padding: const EdgeInsets.all(12), child: Text( _errorMessage, style: TextStyle( color: Theme.of(context) .colorScheme .onErrorContainer, ), ), ), ), if (_quote != null) ...[ _PayoutQuoteCard( quote: _quote!, check: _check, otpSent: _otpSent, paymentSummary: _paymentSummary, ), const SizedBox(height: 12), ], if (_otpSent) ...[ Text( context.tr('Enter the 8-digit code sent to you.'), style: Theme.of(context).textTheme.bodySmall, ), const SizedBox(height: 8), CardeoOtpField( controller: _otpController, length: 8, enabled: !_loading, label: context.tr('OTP code'), ), const SizedBox(height: 12), OutlinedButton.icon( onPressed: _loading ? null : _resendOtp, icon: const Icon(Icons.refresh), label: Text(context.tr('Resend code')), ), ], const SizedBox(height: 12), if (isCardeo) // The decisive action of the money surface, and the only // glass button on it. Its label walks the flow — Show fee, // Send OTP code, Confirm payment — but the silhouette, // width and position never move, so the commit target sits // in the same place from the first tap to the last. No // sheen: this screen already spends its sweep on the // balance panel's hairline and the section title, and a // third host would turn a rhythm into a shimmer. CardeoGlassButton( label: _ctaLabel, icon: _otpSent ? Icons.verified_user : Icons.send, loading: _loading, loadingSemanticsLabel: '$_ctaLabel, in progress', onPressed: _ctaAction( selectedPayee, selectedAccount, canRequestQuote: canRequestQuote, ), ) else FilledButton.icon( onPressed: _ctaAction( selectedPayee, selectedAccount, canRequestQuote: canRequestQuote, ), icon: Icon(_otpSent ? Icons.verified_user : Icons.send), label: Text( _loading ? context.tr('Processing...') : _ctaLabel), ), ], ), ); }, ); }, error: (error, stackTrace) => _MoneyErrorState( error: error, onRetry: () => ref.invalidate(payeesProvider), ), loading: () => LoadingState(label: context.tr('Loading payees')), ), error: (error, stackTrace) => _MoneyErrorState( error: error, onRetry: () => ref.invalidate(accountsProvider), ), loading: () => LoadingState(label: context.tr('Loading accounts')), ); } Future<void> _requestQuote( Payee selectedPayee, AccountBalance selectedAccount, ) async { if (!_formKey.currentState!.validate()) { return; } try { setState(() { _loading = true; _errorMessage = ''; }); await _refreshQuote(selectedPayee, selectedAccount, showErrors: true); _startQuoteRefresh(selectedPayee, selectedAccount); } catch (error) { setState(() => _errorMessage = friendlyErrorMessage(error)); } finally { if (mounted) { setState(() => _loading = false); } } } Future<void> _sendOtp( Payee selectedPayee, AccountBalance selectedAccount, ) async { if (!_formKey.currentState!.validate()) { return; } try { setState(() { _loading = true; _errorMessage = ''; }); final quote = _quote ?? await _refreshQuote( selectedPayee, selectedAccount, showErrors: true, ); final api = ref.read(mobileBankingApiProvider); final amount = _amountValue(); final check = await api.checkPayout( payeeId: selectedPayee.id, quotationId: quote.id, amount: amount, currency: _currency!, memo: _referenceController.text.trim(), ); if (!check.pass) { setState(() { _check = check; _errorMessage = check.message.isEmpty ? 'The payout check did not pass.' : check.message; }); return; } _quoteRefreshTimer?.cancel(); final otp = await api.initiatePayoutOtp( checkId: check.id, quoteRequestId: quote.quoteRequestId, payeeId: selectedPayee.id, amount: amount, currency: _currency!, balanceId: selectedAccount.id, fromCurrency: quote.sourceCurrency, ); if (!otp.success || otp.verificationToken.isEmpty) { throw StateError( otp.message.isEmpty ? 'OTP could not be sent.' : otp.message, ); } setState(() { _quote = quote; _paymentSummary = otp.paymentSummary; _check = check; _verificationToken = otp.verificationToken; _otpSent = true; _otpController.clear(); }); _quoteRefreshTimer?.cancel(); } catch (error) { setState(() => _errorMessage = friendlyErrorMessage(error)); } finally { if (mounted) { setState(() => _loading = false); } } } Future<void> _confirmPayout() async { final otpCode = _otpController.text.trim(); if (otpCode.length != 8) { setState(() => _errorMessage = 'Enter the full 8-digit OTP code.'); return; } if (_verificationToken.isEmpty || _payeeKey == null || _accountKey == null) { setState( () => _errorMessage = 'Request a new OTP code before confirming.'); return; } final accounts = ref.read(accountsProvider).valueOrNull ?? const []; final payees = ref.read(payeesProvider).valueOrNull ?? const []; if (accounts.isEmpty || payees.isEmpty) { setState( () => _errorMessage = 'Accounts or payees are no longer available.'); return; } final selectedAccount = _selectedAccountEntry(_accountOptions(accounts)).account; final selectedPayee = _selectedPayeeEntry(_payeeOptions(payees)).payee; try { setState(() { _loading = true; _errorMessage = ''; }); final api = ref.read(mobileBankingApiProvider); final latestQuote = _quote; if (latestQuote == null) throw StateError('Request a new payment code.'); final verification = await api.verifyPayoutOtp( verificationToken: _verificationToken, otpCode: otpCode, ); if (!verification.success) { throw StateError( verification.message.isEmpty ? 'The OTP code was not accepted.' : verification.message, ); } final verifiedToken = verification.verificationToken.isEmpty ? _verificationToken : verification.verificationToken; await api.confirmPayout( checkId: _check?.id ?? '', quoteRequestId: latestQuote.quoteRequestId, verificationToken: verifiedToken, payeeId: selectedPayee.id, amount: _amountValue(), currency: _currency!, balanceId: selectedAccount.id, fromCurrency: latestQuote.sourceCurrency, ); _quoteRefreshTimer?.cancel(); ref.invalidate(transactionsProvider); ref.invalidate(activityTransactionsProvider); ref.invalidate(activityAccountTransactionsProvider); ref.invalidate(activityCardTransactionsProvider); ref.invalidate(accountsProvider); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.tr('Payment submitted'))), ); setState(() => _resetPayoutState()); } } catch (error) { setState(() => _errorMessage = friendlyErrorMessage(error)); } finally { if (mounted) { setState(() => _loading = false); } } } Future<void> _resendOtp() async { if (_verificationToken.isEmpty) { return; } try { setState(() { _loading = true; _errorMessage = ''; }); final otp = await ref .read(mobileBankingApiProvider) .resendPayoutOtp(verificationToken: _verificationToken); if (!otp.success) { throw StateError( otp.message.isEmpty ? 'OTP resend failed.' : otp.message); } if (!mounted) { return; } ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(otp.message.isEmpty ? context.tr('OTP code resent') : otp.message), ), ); } catch (error) { setState(() => _errorMessage = friendlyErrorMessage(error)); } finally { if (mounted) { setState(() => _loading = false); } } } Future<PayoutQuote> _refreshQuote( Payee selectedPayee, AccountBalance selectedAccount, { required bool showErrors, }) async { final quote = await ref.read(mobileBankingApiProvider).createPayoutQuote( payeeId: selectedPayee.id, amount: _amountValue(), sourceCurrency: _currency!, targetCurrency: fallbackText(selectedPayee.currency, _currency!), lastQuoteId: _quote?.id, ); if (mounted && !_otpSent) { setState(() { _quote = quote; if (showErrors) { _errorMessage = ''; } }); } return quote; } void _startQuoteRefresh( Payee selectedPayee, AccountBalance selectedAccount, ) { _quoteRefreshTimer?.cancel(); _quoteRefreshTimer = Timer.periodic(const Duration(seconds: 10), (_) { if (!mounted || _quote == null) { return; } _refreshQuoteSilently(selectedPayee, selectedAccount); }); } Future<void> _refreshQuoteSilently( Payee selectedPayee, AccountBalance selectedAccount, ) async { try { await _refreshQuote( selectedPayee, selectedAccount, showErrors: false, ); } catch (_) {} } void _resetPayoutState() { _quoteRefreshTimer?.cancel(); _quote = null; _paymentSummary = null; _check = null; _verificationToken = ''; _errorMessage = ''; _otpSent = false; _otpController.clear(); } double _amountValue() { return double.tryParse( _amountController.text.trim().replaceAll(',', '.')) ?? 0; } _PaymentAccountOption _selectedAccountEntry( List<_PaymentAccountOption> accounts, ) { final selectedKey = _accountKey; if (selectedKey != null) { for (final option in accounts) { if (option.key == selectedKey) { return option; } } } return accounts.first; } _PaymentPayeeOption _selectedPayeeEntry(List<_PaymentPayeeOption> payees) { final selectedKey = _payeeKey; if (selectedKey != null) { for (final option in payees) { if (option.key == selectedKey) { return option; } } } return payees.first; }} class _PayeesView extends ConsumerStatefulWidget { const _PayeesView({this.initialDraft}); final BankPayeeDraft? initialDraft; @override ConsumerState<_PayeesView> createState() => _PayeesViewState();} class _PaymentAccountOption { const _PaymentAccountOption({ required this.key, required this.account, }); final String key; final AccountBalance account;} class _AvailableBalanceHint extends StatelessWidget { const _AvailableBalanceHint({ required this.account, required this.currency, }); final AccountBalance account; final String currency; @override Widget build(BuildContext context) { final matches = account.currencyBalances .where((balance) => balance.currency.toUpperCase() == currency) .firstOrNull; final available = matches ?? (account.available.currency.toUpperCase() == currency ? account.available : null); return Row( children: [ Icon( Icons.account_balance_wallet_outlined, size: 18, color: Theme.of(context).colorScheme.onSurfaceVariant, ), const SizedBox(width: 8), // A natural-width label in a Row overflows before it wraps; the hint // is the longest string on the Pay form at a 1.3 text scale. Expanded( child: Text( available == null ? context.tr( '{p0} balance available after fee check', {'p0': currency}) : context.tr('{p0} available', {'p0': available.formatted}), style: Theme.of(context).textTheme.bodySmall, ), ), ], ); }} class _PaymentPayeeOption { const _PaymentPayeeOption({ required this.key, required this.payee, }); final String key; final Payee payee;} List<_PaymentAccountOption> _accountOptions(List<AccountBalance> accounts) { return [ for (var index = 0; index < accounts.length; index++) _PaymentAccountOption( key: '${accounts[index].id}#$index', account: accounts[index], ), ];} List<_PaymentPayeeOption> _payeeOptions(List<Payee> payees) { return [ for (var index = 0; index < payees.length; index++) _PaymentPayeeOption( key: '${payees[index].id}#$index', payee: payees[index], ), ];} class _PayeesViewState extends ConsumerState<_PayeesView> { final _formKey = GlobalKey<FormState>(); final _displayNameController = TextEditingController(); final _firstNameController = TextEditingController(); final _lastNameController = TextEditingController(); final _accountHolderController = TextEditingController(); final _accountController = TextEditingController(); final _bankNameController = TextEditingController(); final _routingCodeController = TextEditingController(); final _bankAddressController = TextEditingController(); final _bankAddress2Controller = TextEditingController(); final _bankCityController = TextEditingController(); final _bankStateController = TextEditingController(); final _bankPostalCodeController = TextEditingController(); final _payeeAddressController = TextEditingController(); final _payeeAddress2Controller = TextEditingController(); final _payeeCityController = TextEditingController(); final _payeeStateController = TextEditingController(); final _payeePostalCodeController = TextEditingController(); final _commentsController = TextEditingController(); final _payeeOtpController = TextEditingController(); String _paymentType = 'INDIVIDUAL'; String _paymentMethod = 'SEPA.CREDITTRANSFER'; String _currency = 'EUR'; String _countryCode = ''; String _bankCountryCode = ''; String _payeeCountryCode = ''; String _routingCodeType = 'BIC'; Map<String, dynamic>? _payeeNameCheck; bool _payeeCheckAcknowledged = false; bool get _payeeCheckAllowsConfirm => _payeeNameCheck == null || (_payeeNameCheck!['canProceed'] != false && (!PayeeNameCheck.needsAcknowledgement(_payeeNameCheck!) || _payeeCheckAcknowledged)); String _payeeVerificationToken = ''; String _payeeErrorMessage = ''; String _payeeStatusMessage = ''; bool _payeeOtpSent = false; bool _payeeLoading = false; bool _clearingPayeeForm = false; bool _showPayeeForm = false; @override void initState() { super.initState(); final draft = widget.initialDraft; if (draft != null) { _showPayeeForm = true; _displayNameController.text = 'My ZBX account'; _accountHolderController.text = draft.accountHolder; _accountController.text = draft.iban; _routingCodeController.text = draft.bic; _bankNameController.text = draft.bankName; _bankAddressController.text = draft.bankAddress; _countryCode = draft.bankCountry; _bankCountryCode = draft.bankCountry; // The recipient may live in a different country from the receiving bank. _payeeCountryCode = ''; } for (final controller in [ _displayNameController, _firstNameController, _lastNameController, _accountHolderController, _accountController, _bankNameController, _routingCodeController, _bankAddressController, _bankAddress2Controller, _bankCityController, _bankStateController, _bankPostalCodeController, _payeeAddressController, _payeeAddress2Controller, _payeeCityController, _payeeStateController, _payeePostalCodeController, _commentsController, ]) { controller.addListener(_resetPayeeVerificationIfStarted); } } @override void dispose() { _displayNameController.dispose(); _firstNameController.dispose(); _lastNameController.dispose(); _accountHolderController.dispose(); _accountController.dispose(); _bankNameController.dispose(); _routingCodeController.dispose(); _bankAddressController.dispose(); _bankAddress2Controller.dispose(); _bankCityController.dispose(); _bankStateController.dispose(); _bankPostalCodeController.dispose(); _payeeAddressController.dispose(); _payeeAddress2Controller.dispose(); _payeeCityController.dispose(); _payeeStateController.dispose(); _payeePostalCodeController.dispose(); _commentsController.dispose(); _payeeOtpController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final payees = ref.watch(payeesProvider); return payees.when( data: (items) => Form( key: _formKey, child: ListView( padding: const EdgeInsets.all(16), children: [ Text(context.tr('Saved payees'), style: Theme.of(context).textTheme.titleLarge), const SizedBox(height: 8), if (context.isCardeoTheme) _CardeoPayeeList(payees: items) else if (items.isEmpty) Card( child: ListTile( leading: const Icon(Icons.people_outline), title: Text(context.tr('No payees saved')), subtitle: Text(context.tr('Add a recipient below before paying.')), ), ) else for (final payee in items) Card( child: ListTile( leading: Icon( payee.requiresConfirmation ? Icons.pending_actions : Icons.person_outline, ), title: Text(_payeeLabel(payee)), subtitle: Text(_payeeSubtitle(payee)), ), ), const Divider(height: 32), if (widget.initialDraft != null && _showPayeeForm) ...[ Text(context.tr( 'Review the bank details and complete the recipient information before saving. Payment routes are validated when you submit.')), if (widget.initialDraft!.paymentReference.isNotEmpty) SelectableText(context.tr( 'Use payment reference {p0} when transferring funds.', {'p0': widget.initialDraft!.paymentReference})), const SizedBox(height: 16), ], if (!_showPayeeForm) Row( children: [ Expanded( // The payees tab's opening move, and its only decisive // one while the form is closed — so on Cardeo it is the // same glass object the tab's commit uses further down, // instead of a Material slab that made this one tab // answer "what do I press" with two different materials. // The two never co-render: this branch is `!_showPayeeForm` // and the glass commit lives in the `else`. // // `surface` ground: this is a plain scrolling ListView on // the scaffold, with nothing painted behind it to sample. // Height stays at the widget's 54 default, which is the // theme's own filled/outlined minimum, so the Scan QR // button beside it keeps its exact alignment. child: context.isCardeoTheme ? CardeoGlassButton( label: context.tr('Add payee'), icon: Icons.person_add_alt, onPressed: () => setState(() => _showPayeeForm = true), ) : FilledButton.icon( onPressed: () => setState(() => _showPayeeForm = true), icon: const Icon(Icons.person_add_alt), label: Text(context.tr('Add payee')), ), ), const SizedBox(width: 10), OutlinedButton.icon( onPressed: _payeeLoading ? null : _scanPayeeQr, icon: const Icon(Icons.qr_code_scanner), label: Text(context.tr('Scan QR')), ), ], ) else ...[ Row( children: [ Expanded( child: Text( context.tr('Add payee'), style: Theme.of(context).textTheme.titleLarge, ), ), FilledButton.tonalIcon( onPressed: _payeeLoading ? null : _scanPayeeQr, icon: const Icon(Icons.qr_code_scanner), label: Text(context.tr('Scan QR')), ), IconButton( tooltip: context.tr('Close form'), onPressed: _payeeLoading ? null : () => setState(() => _showPayeeForm = false), icon: const Icon(Icons.close), ), ], ), const SizedBox(height: 12), TextFormField( controller: _displayNameController, decoration: InputDecoration(labelText: context.tr('Display name')), ), const SizedBox(height: 12), Row( children: [ Expanded( child: TextFormField( controller: _firstNameController, decoration: InputDecoration(labelText: context.tr('First name')), validator: _required, ), ), const SizedBox(width: 12), Expanded( child: TextFormField( controller: _lastNameController, decoration: InputDecoration(labelText: context.tr('Last name')), validator: _required, ), ), ], ), const SizedBox(height: 12), TextFormField( controller: _accountHolderController, decoration: InputDecoration( labelText: context.tr('Account holder name')), validator: _required, ), const SizedBox(height: 12), Row( children: [ Expanded( child: _SelectField( label: context.tr('Currency'), value: _currency, options: const ['EUR', 'GBP', 'USD'], onChanged: (value) => setState(() { _currency = value; _resetPayeeVerification(); }), ), ), const SizedBox(width: 12), Expanded( child: PayeeCountryField( label: context.tr('Account country'), value: _countryCode, onChanged: (value) => setState(() { _countryCode = value; _resetPayeeVerification(); }), ), ), ], ), const SizedBox(height: 12), _SelectField( label: context.tr('Payment type'), value: _paymentType, options: const ['INDIVIDUAL', 'COMPANY'], onChanged: (value) => setState(() { _paymentType = value; _resetPayeeVerification(); }), ), const SizedBox(height: 12), _SelectField( label: context.tr('Payment method'), value: _paymentMethod, options: const [ 'SEPA.CREDITTRANSFER', 'SEPA.INSTANT', 'SWIFT', 'CHAPS', 'UKFPS', 'ACH', 'WIRETRANSFER', 'OFF_PLATFORM', ], onChanged: (value) => setState(() { _paymentMethod = value; _resetPayeeVerification(); }), ), const SizedBox(height: 12), TextFormField( controller: _accountController, decoration: InputDecoration( labelText: context.tr('IBAN or account number')), validator: _required, ), const SizedBox(height: 12), Row( children: [ Expanded( child: _SelectField( label: context.tr('Routing type'), value: _routingCodeType, options: const ['BIC', 'SWIFT', 'SORT_CODE', 'ABA'], onChanged: (value) => setState(() { _routingCodeType = value; _resetPayeeVerification(); }), ), ), const SizedBox(width: 12), Expanded( child: TextFormField( controller: _routingCodeController, decoration: InputDecoration( labelText: context.tr('Routing code')), ), ), ], ), const SizedBox(height: 12), TextFormField( controller: _bankNameController, decoration: InputDecoration(labelText: context.tr('Bank name')), ), const SizedBox(height: 12), Text(context.tr('Bank address'), style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 12), PayeeCountryField( label: context.tr('Bank country'), value: _bankCountryCode, onChanged: (value) => setState(() { _bankCountryCode = value; _resetPayeeVerification(); }), ), const SizedBox(height: 12), TextFormField( controller: _bankAddressController, decoration: InputDecoration( labelText: context.tr('Bank address line 1')), ), const SizedBox(height: 12), TextFormField( controller: _bankAddress2Controller, decoration: InputDecoration( labelText: context.tr('Bank address line 2')), ), const SizedBox(height: 12), Row( children: [ Expanded( child: TextFormField( controller: _bankCityController, decoration: InputDecoration(labelText: context.tr('Bank city')), ), ), const SizedBox(width: 12), Expanded( child: TextFormField( controller: _bankStateController, decoration: InputDecoration(labelText: context.tr('Bank state')), ), ), ], ), const SizedBox(height: 12), TextFormField( controller: _bankPostalCodeController, decoration: InputDecoration(labelText: context.tr('Bank postal code')), ), const SizedBox(height: 16), Text(context.tr('Payee address'), style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 12), PayeeCountryField( label: context.tr('Payee country'), value: _payeeCountryCode, onChanged: (value) => setState(() { _payeeCountryCode = value; _resetPayeeVerification(); }), ), const SizedBox(height: 12), TextFormField( controller: _payeeAddressController, decoration: InputDecoration( labelText: context.tr('Payee address line 1')), ), const SizedBox(height: 12), TextFormField( controller: _payeeAddress2Controller, decoration: InputDecoration( labelText: context.tr('Payee address line 2')), ), const SizedBox(height: 12), Row( children: [ Expanded( child: TextFormField( controller: _payeeCityController, decoration: InputDecoration(labelText: context.tr('Payee city')), ), ), const SizedBox(width: 12), Expanded( child: TextFormField( controller: _payeeStateController, decoration: InputDecoration(labelText: context.tr('Payee state')), ), ), ], ), const SizedBox(height: 12), TextFormField( controller: _payeePostalCodeController, decoration: InputDecoration(labelText: context.tr('Payee postal code')), ), const SizedBox(height: 12), TextFormField( controller: _commentsController, decoration: InputDecoration(labelText: context.tr('Comments')), maxLines: 2, ), if (_payeeErrorMessage.isNotEmpty) ...[ const SizedBox(height: 12), Card( color: Theme.of(context).colorScheme.errorContainer, child: Padding( padding: const EdgeInsets.all(12), child: Text( _payeeErrorMessage, style: TextStyle( color: Theme.of(context).colorScheme.onErrorContainer, ), ), ), ), ], if (_payeeStatusMessage.isNotEmpty) ...[ const SizedBox(height: 12), Card( child: ListTile( leading: const Icon(Icons.mark_email_read_outlined), title: Text(context.tr('Verification code sent')), subtitle: Text(_payeeStatusMessage), ), ), ], if (_payeeOtpSent && _payeeNameCheck != null) PayeeNameCheck( result: _payeeNameCheck!, acknowledged: _payeeCheckAcknowledged, onChanged: (value) => setState(() => _payeeCheckAcknowledged = value)), if (_payeeOtpSent) ...[ const SizedBox(height: 12), Text( context.tr('Enter the 8-digit code sent to you.'), style: Theme.of(context).textTheme.bodySmall, ), const SizedBox(height: 8), CardeoOtpField( controller: _payeeOtpController, length: 8, enabled: !_payeeLoading, label: context.tr('OTP code'), ), const SizedBox(height: 12), OutlinedButton.icon( onPressed: _payeeLoading ? null : _resendPayeeOtp, icon: const Icon(Icons.refresh), label: Text(context.tr('Resend code')), ), ], const SizedBox(height: 16), if (context.isCardeoTheme) // The payee tab's commit, in the same material and the same // place as the pay tab's — the two tabs of one screen should // not answer "what do I press to finish" with two different // objects. Label walks Send OTP code -> Confirm payee while // the silhouette holds. CardeoGlassButton( label: _payeeOtpSent ? context.tr('Confirm payee') : context.tr('Send OTP code'), icon: _payeeOtpSent ? Icons.verified_user : Icons.person_add_alt, loading: _payeeLoading, onPressed: _payeeLoading || (_payeeOtpSent && !_payeeCheckAllowsConfirm) ? null : (_payeeOtpSent ? _confirmPayee : _startPayeeOtp), ) else FilledButton.icon( onPressed: _payeeLoading || (_payeeOtpSent && !_payeeCheckAllowsConfirm) ? null : (_payeeOtpSent ? _confirmPayee : _startPayeeOtp), icon: Icon(_payeeOtpSent ? Icons.verified_user : Icons.person_add_alt), label: Text( _payeeLoading ? context.tr('Processing...') : _payeeOtpSent ? context.tr('Confirm payee') : context.tr('Send OTP code'), ), ), ], ], ), ), error: (error, stackTrace) => _MoneyErrorState( error: error, onRetry: () => ref.invalidate(payeesProvider), ), loading: () => LoadingState(label: context.tr('Loading payees')), ); } Future<void> _startPayeeOtp() async { if (!(_formKey.currentState?.validate() ?? false)) { return; } final candidate = BankPayeeDraft( iban: _accountController.text, accountHolder: _accountHolderController.text, bankCountry: _bankCountryCode); if (_currency == 'EUR' && (ref.read(payeesProvider).valueOrNull ?? []).any(candidate.matches)) { setState(() => _payeeErrorMessage = context.tr( 'This account is already in your payees. Use the saved recipient or complete its pending approval.')); return; } try { setState(() { _payeeLoading = true; _payeeErrorMessage = ''; _payeeStatusMessage = ''; }); final result = await ref.read(mobileBankingApiProvider).initiatePayeeCreation( paymentType: _paymentType, currency: _currency, paymentMethod: _paymentMethod, countryCode: _countryCode, firstName: _firstNameController.text.trim(), lastName: _lastNameController.text.trim(), accountNumber: _accountController.text.trim(), userName: _accountHolderController.text.trim(), displayName: _displayNameController.text.trim(), bankName: _bankNameController.text.trim(), routingCodeType: _routingCodeType, routingCodeValue: _routingCodeController.text.trim(), bankCountry: _bankCountryCode, bankAddressLine1: _bankAddressController.text.trim(), bankAddressLine2: _bankAddress2Controller.text.trim(), bankCity: _bankCityController.text.trim(), bankState: _bankStateController.text.trim(), bankPostalCode: _bankPostalCodeController.text.trim(), payeeCountry: _payeeCountryCode, payeeAddressLine1: _payeeAddressController.text.trim(), payeeAddressLine2: _payeeAddress2Controller.text.trim(), payeeCity: _payeeCityController.text.trim(), payeeState: _payeeStateController.text.trim(), payeePostalCode: _payeePostalCodeController.text.trim(), comments: _commentsController.text.trim(), ); if (!result.success || result.verificationToken.isEmpty) { throw StateError( result.message.isEmpty ? 'OTP could not be sent.' : result.message, ); } setState(() { _payeeNameCheck = result.verificationResult?.map((key, value) => MapEntry( key.isEmpty ? key : key[0].toLowerCase() + key.substring(1), value)); _payeeCheckAcknowledged = false; _payeeVerificationToken = result.verificationToken; _payeeStatusMessage = result.message.isEmpty ? 'Enter the OTP code to save this payee.' : result.message; _payeeOtpSent = true; _payeeOtpController.clear(); }); } catch (error) { setState(() => _payeeErrorMessage = friendlyErrorMessage(error)); } finally { if (mounted) { setState(() => _payeeLoading = false); } } } Future<void> _scanPayeeQr() async { final payload = await Navigator.of(context).push<String>( MaterialPageRoute(builder: (_) => const _PayeeQrScannerScreen()), ); if (payload == null || payload.trim().isEmpty) { return; } final details = _parsePayeeQrPayload(payload); if (!details.hasBankDetails) { setState(() { _payeeErrorMessage = 'This QR code does not include bank account details we can prefill.'; _payeeStatusMessage = payload.trim(); }); return; } setState(() { _showPayeeForm = true; _applyScannedPayee(details); _payeeStatusMessage = 'Payee details filled from QR code.'; _payeeErrorMessage = ''; }); } void _applyScannedPayee(_ScannedPayeeDetails details) { _clearingPayeeForm = true; if (details.displayName != null) { _displayNameController.text = details.displayName!; } if (details.firstName != null) { _firstNameController.text = details.firstName!; } if (details.lastName != null) { _lastNameController.text = details.lastName!; } if (details.accountHolder != null) { _accountHolderController.text = details.accountHolder!; } if (details.accountNumber != null) { _accountController.text = details.accountNumber!; } if (details.bankName != null) { _bankNameController.text = details.bankName!; } if (details.routingCodeValue != null) { _routingCodeController.text = details.routingCodeValue!; } if (details.addressLine1 != null) { _payeeAddressController.text = details.addressLine1!; } if (details.city != null) { _payeeCityController.text = details.city!; } if (details.comments != null) { _commentsController.text = details.comments!; } if (details.countryCode != null) { _countryCode = details.countryCode!; _bankCountryCode = details.countryCode!; } if (details.currency != null) { _currency = details.currency!; } if (details.paymentMethod != null) { _paymentMethod = details.paymentMethod!; } if (details.routingCodeType != null) { _routingCodeType = details.routingCodeType!; } _clearingPayeeForm = false; _resetPayeeVerification(); } Future<void> _confirmPayee() async { if (!_payeeCheckAllowsConfirm) return; final otpCode = _payeeOtpController.text.trim(); if (otpCode.length != 8) { setState(() => _payeeErrorMessage = 'Enter the full 8-digit OTP code.'); return; } if (_payeeVerificationToken.isEmpty) { setState(() => _payeeErrorMessage = 'Request a new OTP code before confirming.'); return; } try { setState(() { _payeeLoading = true; _payeeErrorMessage = ''; }); final verification = await ref.read(mobileBankingApiProvider).verifyPayoutOtp( verificationToken: _payeeVerificationToken, otpCode: otpCode, ); if (!verification.success) { throw StateError( verification.message.isEmpty ? 'The OTP code was not accepted.' : verification.message, ); } await ref.read(mobileBankingApiProvider).confirmPayeeCreation( verificationAcknowledged: _payeeCheckAcknowledged, verificationToken: verification.verificationToken.isEmpty ? _payeeVerificationToken : verification.verificationToken, ); ref.invalidate(payeesProvider); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.tr('Payee saved'))), ); setState(_clearPayeeForm); } } catch (error) { setState(() => _payeeErrorMessage = friendlyErrorMessage(error)); } finally { if (mounted) { setState(() => _payeeLoading = false); } } } Future<void> _resendPayeeOtp() async { if (_payeeVerificationToken.isEmpty) { return; } try { setState(() { _payeeLoading = true; _payeeErrorMessage = ''; }); final result = await ref .read(mobileBankingApiProvider) .resendPayoutOtp(verificationToken: _payeeVerificationToken); if (!result.success) { throw StateError( result.message.isEmpty ? 'OTP resend failed.' : result.message, ); } if (!mounted) { return; } setState(() { _payeeStatusMessage = result.message.isEmpty ? 'OTP code resent.' : result.message; }); } catch (error) { setState(() => _payeeErrorMessage = friendlyErrorMessage(error)); } finally { if (mounted) { setState(() => _payeeLoading = false); } } } void _resetPayeeVerificationIfStarted() { if (_clearingPayeeForm) { return; } if (_payeeOtpSent || _payeeVerificationToken.isNotEmpty) { setState(_resetPayeeVerification); } } void _resetPayeeVerification() { _payeeNameCheck = null; _payeeCheckAcknowledged = false; _payeeVerificationToken = ''; _payeeErrorMessage = ''; _payeeStatusMessage = ''; _payeeOtpSent = false; _payeeOtpController.clear(); } void _clearPayeeForm() { _clearingPayeeForm = true; _displayNameController.clear(); _firstNameController.clear(); _lastNameController.clear(); _accountHolderController.clear(); _accountController.clear(); _bankNameController.clear(); _routingCodeController.clear(); _bankAddressController.clear(); _bankAddress2Controller.clear(); _bankCityController.clear(); _bankStateController.clear(); _bankPostalCodeController.clear(); _payeeAddressController.clear(); _payeeAddress2Controller.clear(); _payeeCityController.clear(); _payeeStateController.clear(); _payeePostalCodeController.clear(); _commentsController.clear(); _countryCode = ''; _bankCountryCode = ''; _payeeCountryCode = ''; _clearingPayeeForm = false; _resetPayeeVerification(); }} /// Saved payees in the Cardeo register: one [CardeoListGroup] of 56 pt/// [CardeoRow]s instead of a stack of Material cards, so the payee list has/// the same rhythm, hairlines and inks as every other Cardeo list — in both/// themes, since the group, the rows, the tile and the pill all resolve their/// surfaces and inks per brightness.////// A utility list: no arrival moment, no stagger, and the rows carry no/// gesture because there is nothing yet to open.class _CardeoPayeeList extends StatelessWidget { const _CardeoPayeeList({required this.payees}); final List<Payee> payees; @override Widget build(BuildContext context) { if (payees.isEmpty) { return CardeoListGroup( children: [ CardeoRow( title: context.tr('No payees saved'), subtitle: context.tr('Add a recipient below before paying.'), leading: const CardeoIconTile( icon: Icons.people_outline, color: CardeoColors.iris, ), ), ], ); } return CardeoListGroup( children: [ for (final payee in payees) CardeoRow( title: _payeeLabel(payee), subtitle: _payeeSubtitle(payee), leading: CardeoIconTile( icon: payee.requiresConfirmation ? Icons.pending_actions_outlined : Icons.person_outline, color: payee.requiresConfirmation ? CardeoColors.warning : CardeoColors.iris, ), trailing: payee.requiresConfirmation ? CardeoPill( label: context.tr('Pending'), color: CardeoColors.warning, dot: true, ) : null, ), ], ); }} class _PayeeQrScannerScreen extends StatefulWidget { const _PayeeQrScannerScreen(); @override State<_PayeeQrScannerScreen> createState() => _PayeeQrScannerScreenState();} class _PayeeQrScannerScreenState extends State<_PayeeQrScannerScreen> { bool _handled = false; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text(context.tr('Scan account QR'))), body: Stack( children: [ MobileScanner( onDetect: (capture) { if (_handled) { return; } final rawValue = capture.barcodes .map((barcode) => barcode.rawValue) .whereType<String>() .firstOrNull; if (rawValue == null || rawValue.trim().isEmpty) { return; } _handled = true; Navigator.of(context).pop(rawValue); }, ), Align( alignment: Alignment.bottomCenter, child: Container( width: double.infinity, color: Colors.black54, padding: const EdgeInsets.all(16), child: Text( context.tr('Point the camera at a bank account QR code.'), textAlign: TextAlign.center, style: const TextStyle(color: Colors.white), ), ), ), ], ), ); }} class _ScannedPayeeDetails { const _ScannedPayeeDetails({ this.displayName, this.firstName, this.lastName, this.accountHolder, this.accountNumber, this.bankName, this.routingCodeType, this.routingCodeValue, this.addressLine1, this.city, this.countryCode, this.currency, this.paymentMethod, this.comments, }); final String? displayName; final String? firstName; final String? lastName; final String? accountHolder; final String? accountNumber; final String? bankName; final String? routingCodeType; final String? routingCodeValue; final String? addressLine1; final String? city; final String? countryCode; final String? currency; final String? paymentMethod; final String? comments; bool get hasBankDetails { return (accountNumber?.trim().isNotEmpty ?? false) || (routingCodeValue?.trim().isNotEmpty ?? false); }} _ScannedPayeeDetails _parsePayeeQrPayload(String rawPayload) { final payload = rawPayload.trim(); final epc = _parseEpcQrPayload(payload); if (epc != null) { return epc; } final upn = _parseUpnQrPayload(payload); if (upn != null) { return upn; } final values = _labelledQrValues(payload); final accountNumber = _firstValue(values, const [ 'account number', 'iban', 'st racuna', 'st. racuna', 'št racuna', 'št. racuna', 'št. računa', ]); final accountHolder = _firstValue(values, const [ 'account holder', 'naziv', 'name', 'user name', 'recipient', ]); final bankName = _firstValue(values, const ['bank', 'bank name']); final swift = _firstValue(values, const [ 'swift/bic', 'swift', 'bic', 'swift code', ]); final sortCode = _firstValue(values, const ['sort code', 'sort_code']); final address = _firstValue(values, const ['address', 'naslov']); final city = _firstValue(values, const ['city', 'kraj']); final currencies = _firstValue(values, const [ 'available currencies', 'currency', ]); final preferredCurrency = _firstCurrency(currencies); final country = _countryFromAccount(accountNumber); return _ScannedPayeeDetails( displayName: accountHolder, firstName: _firstNameFromFullName(accountHolder), lastName: _lastNameFromFullName(accountHolder), accountHolder: accountHolder, accountNumber: accountNumber?.replaceAll(' ', ''), bankName: bankName, routingCodeType: sortCode != null ? 'SORT_CODE' : swift == null ? null : 'BIC', routingCodeValue: sortCode ?? swift, addressLine1: address, city: city, countryCode: country, currency: preferredCurrency ?? _currencyFromCountry(country), paymentMethod: _paymentMethodForScannedAccount( accountNumber: accountNumber, countryCode: country, currency: preferredCurrency, hasSortCode: sortCode != null, ), );} _ScannedPayeeDetails? _parseUpnQrPayload(String payload) { final lines = payload .split(RegExp(r'\r?\n')) .map((line) => line.trim()) .where((line) => line.isNotEmpty) .toList(); if (lines.isEmpty || !lines.first.toUpperCase().startsWith('UPNQR')) { return null; } final accountIndex = lines.indexWhere(_looksLikeIban); if (accountIndex >= 0) { final accountNumber = _cleanAccountNumber(lines[accountIndex]); final accountHolder = _lineAfter(lines, accountIndex); final addressLine1 = _lineAfter(lines, accountIndex + 1); final city = _lineAfter(lines, accountIndex + 2); final comments = _lineAfter(lines, accountIndex + 3); return _upnDetails( accountNumber: accountNumber, accountHolder: accountHolder, addressLine1: addressLine1, city: city, comments: comments, ); } final compact = payload.replaceAll(RegExp(r'\s+'), ' ').trim(); final ibanMatch = RegExp(r'\bSI\d{2}\s?\d{4}\s?\d{4}\s?\d{4}\s?\d{3}\b') .firstMatch(compact.toUpperCase()); if (ibanMatch == null) { return null; } final accountNumber = _cleanAccountNumber(ibanMatch.group(0)); final remainder = compact.substring(ibanMatch.end).trim(); final parts = _splitCompactUpnRemainder(remainder); return _upnDetails( accountNumber: accountNumber, accountHolder: parts.accountHolder, addressLine1: parts.addressLine1, city: parts.city, comments: parts.comments, );} _ScannedPayeeDetails? _parseEpcQrPayload(String payload) { final lines = payload.split(RegExp(r'\r?\n')).map((line) => line.trim()).toList(); if (lines.length < 7 || lines.first != 'BCD') { return null; } final swift = lines.length > 4 ? lines[4] : ''; final name = lines.length > 5 ? lines[5] : ''; final iban = lines.length > 6 ? lines[6] : ''; final amountLine = lines.length > 7 ? lines[7] : ''; final currency = amountLine.length >= 3 ? amountLine.substring(0, 3) : null; final country = _countryFromAccount(iban); return _ScannedPayeeDetails( displayName: name, firstName: _firstNameFromFullName(name), lastName: _lastNameFromFullName(name), accountHolder: name, accountNumber: iban, routingCodeType: swift.isEmpty ? null : 'BIC', routingCodeValue: swift.isEmpty ? null : swift, countryCode: country, currency: currency, paymentMethod: 'SEPA.CREDITTRANSFER', );} _ScannedPayeeDetails _upnDetails({ required String accountNumber, String? accountHolder, String? addressLine1, String? city, String? comments,}) { final country = _countryFromAccount(accountNumber); return _ScannedPayeeDetails( displayName: accountHolder, firstName: _firstNameFromFullName(accountHolder), lastName: _lastNameFromFullName(accountHolder), accountHolder: accountHolder, accountNumber: accountNumber, addressLine1: addressLine1, city: city, countryCode: country, currency: _currencyFromCountry(country), paymentMethod: 'SEPA.CREDITTRANSFER', comments: comments, );} bool _looksLikeIban(String value) { return RegExp(r'^[A-Z]{2}\d{2}(?:\s?[A-Z0-9]){8,30}$') .hasMatch(value.toUpperCase());} String _cleanAccountNumber(String? value) { return (value ?? '').replaceAll(RegExp(r'\s+'), '').toUpperCase();} String? _lineAfter(List<String> lines, int index) { final valueIndex = index + 1; if (valueIndex >= lines.length) { return null; } final value = lines[valueIndex].trim(); return value.isEmpty ? null : value;} ({String? accountHolder, String? addressLine1, String? city, String? comments}) _splitCompactUpnRemainder(String remainder) { final tokens = remainder .split(RegExp(r'\s+')) .where((token) => token.trim().isNotEmpty) .toList(); if (tokens.isEmpty) { return ( accountHolder: null, addressLine1: null, city: null, comments: null, ); } String? comments; if (tokens.length > 1 && RegExp(r'^\d{1,4}$').hasMatch(tokens.last)) { comments = tokens.removeLast(); } final streetIndex = _compactStreetIndex(tokens); if (streetIndex > 0) { final addressStart = streetIndex - 1; var addressEnd = tokens.length; for (var index = streetIndex + 1; index < tokens.length; index++) { if (RegExp(r'\d').hasMatch(tokens[index])) { addressEnd = index + 1; break; } } final accountHolder = tokens.take(addressStart).join(' ').trim(); final addressLine1 = tokens.skip(addressStart).take(addressEnd - addressStart).join(' '); final city = tokens.skip(addressEnd).join(' ').trim(); return ( accountHolder: accountHolder.isEmpty ? null : accountHolder, addressLine1: addressLine1.isEmpty ? null : addressLine1, city: city.isEmpty ? null : city, comments: comments, ); } if (tokens.length >= 4) { final accountHolder = tokens.take(2).join(' '); final city = tokens.length > 3 ? tokens.last : null; final addressLine1 = tokens.skip(2).take(tokens.length - (city == null ? 2 : 3)).join(' '); return ( accountHolder: accountHolder, addressLine1: addressLine1.isEmpty ? null : addressLine1, city: city, comments: comments, ); } return ( accountHolder: tokens.join(' '), addressLine1: null, city: null, comments: comments, );} int _compactStreetIndex(List<String> tokens) { const streetWords = { 'CESTA', 'ULICA', 'POT', 'TRG', 'NASELJE', 'KOLONIJA', 'BREG', 'OBALA', 'GASA', 'GAJ', 'POLJE', }; return tokens.indexWhere((token) { final normalized = _normalizeQrLabel(token).toUpperCase(); return streetWords.contains(normalized); });} Map<String, String> _labelledQrValues(String payload) { final values = <String, String>{}; for (final line in payload.split(RegExp(r'\r?\n'))) { final separatorIndex = line.indexOf(':'); if (separatorIndex <= 0) { continue; } final key = _normalizeQrLabel(line.substring(0, separatorIndex)); final value = line.substring(separatorIndex + 1).trim(); if (key.isNotEmpty && value.isNotEmpty) { values[key] = value; } } return values;} String _normalizeQrLabel(String label) { return label .toLowerCase() .replaceAll('č', 'c') .replaceAll('š', 's') .replaceAll('ž', 'z') .replaceAll(RegExp(r'\s+'), ' ') .trim();} String? _firstValue(Map<String, String> values, List<String> keys) { for (final key in keys) { final value = values[_normalizeQrLabel(key)]; if (value != null && value.trim().isNotEmpty) { return value.trim(); } } return null;} String? _firstCurrency(String? value) { if (value == null) { return null; } final match = RegExp(r'\b[A-Z]{3}\b').firstMatch(value.toUpperCase()); return match?.group(0);} String? _countryFromAccount(String? accountNumber) { final trimmed = accountNumber?.replaceAll(' ', '').toUpperCase() ?? ''; if (trimmed.length >= 2 && RegExp(r'^[A-Z]{2}').hasMatch(trimmed)) { return trimmed.substring(0, 2); } return null;} String _currencyFromCountry(String? countryCode) { return switch (countryCode) { 'GB' => 'GBP', _ => 'EUR', };} String _paymentMethodForScannedAccount({ required String? accountNumber, required String? countryCode, required String? currency, required bool hasSortCode,}) { if (hasSortCode || countryCode == 'GB' || currency == 'GBP') { return 'UKFPS'; } if ((accountNumber ?? '') .replaceAll(' ', '') .toUpperCase() .startsWith('SI') || currency == 'EUR') { return 'SEPA.CREDITTRANSFER'; } return 'SWIFT';} String? _firstNameFromFullName(String? value) { final parts = _nameParts(value); if (parts.length <= 1) { return parts.firstOrNull; } return parts.take(parts.length - 1).join(' ');} String? _lastNameFromFullName(String? value) { final parts = _nameParts(value); return parts.isEmpty ? null : parts.last;} List<String> _nameParts(String? value) { return (value ?? '') .trim() .split(RegExp(r'\s+')) .where((part) => part.isNotEmpty) .toList();} class _PayoutQuoteCard extends StatelessWidget { const _PayoutQuoteCard({ required this.quote, required this.check, required this.otpSent, this.paymentSummary, }); final Map<String, dynamic>? paymentSummary; final PayoutQuote quote; final PayoutCheck? check; final bool otpSent; @override Widget build(BuildContext context) { final expiresAt = quote.expiresAt; final isCardeo = context.isCardeoTheme; final body = Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( otpSent ? context.tr('Payment details for this code') : context.tr('Fee and quote'), style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 12), if (otpSent && paymentSummary != null) ...[ _QuoteRow( label: 'Recipient gets', value: '${paymentSummary!['recipientAmount']} ${paymentSummary!['recipientCurrency']}'), _QuoteRow( label: 'Debit before fees', value: '${paymentSummary!['debitAmount']} ${paymentSummary!['debitCurrency']}'), _QuoteRow( label: 'Fee', value: '${paymentSummary!['fee']} ${paymentSummary!['debitCurrency']}'), _QuoteRow( label: 'Total debit', value: '${paymentSummary!['totalDebit']} ${paymentSummary!['debitCurrency']}'), ] else ...[ _QuoteRow( label: context.tr('Sending'), value: '${quote.sourceCurrency} ${quote.sourceAmount.toStringAsFixed(2)}', ), _QuoteRow( label: context.tr('Recipient gets'), value: '${quote.targetCurrency} ${quote.targetAmount.toStringAsFixed(2)}', ), _QuoteRow(label: context.tr('Fee'), value: quote.feeLabel), _QuoteRow(label: context.tr('Rate'), value: quote.rateLabel), ], if (expiresAt != null && !(otpSent && paymentSummary != null)) _QuoteRow( label: context.tr('Expires'), value: TimeOfDay.fromDateTime(expiresAt.toLocal()).format(context), ), if (check != null) _QuoteRow( label: context.tr('Validation'), value: check!.pass ? 'Passed' : fallbackText(check!.message, 'Blocked'), ), ], ); // Cardeo reads the quote off a matte panel with the daylight and Twilight // surfaces the rest of the app uses; every other brand keeps its Material // card, byte for byte. if (isCardeo) { return CardeoGlassPanel( radius: AppRadii.lg, padding: const EdgeInsets.all(16), child: body, ); } return Card( child: Padding(padding: const EdgeInsets.all(16), child: body), ); }} class _QuoteRow extends StatelessWidget { const _QuoteRow({required this.label, required this.value}); final String label; final String value; @override Widget build(BuildContext context) { final theme = Theme.of(context); final isCardeo = context.isCardeoTheme; // Cardeo flexes the label column instead of pinning it to 108 px, so a // 1.3 text scale widens it instead of wrapping it, and sets the value in // tabular figures so the quote's numbers line up. Other brands keep the // fixed column they render today. final labelText = Text( label, style: isCardeo ? theme.textTheme.bodySmall?.copyWith( color: CardeoInk.secondary(context), ) : theme.textTheme.bodySmall, ); return Padding( padding: const EdgeInsets.only(bottom: 8), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (isCardeo) ...[ Flexible(flex: 4, child: labelText), const SizedBox(width: AppSpacing.sm), ] else SizedBox(width: 108, child: labelText), Expanded( flex: isCardeo ? 6 : 1, child: Text( value, textAlign: TextAlign.end, style: isCardeo ? theme.textTheme.bodyMedium?.copyWith( color: CardeoInk.primary(context), fontFeatures: const [FontFeature.tabularFigures()], ) : theme.textTheme.bodyMedium, ), ), ], ), ); }} class _AmountField extends StatelessWidget { const _AmountField({ required this.controller, this.currency = 'EUR', this.onChanged, }); final TextEditingController controller; final String currency; final ValueChanged<String>? onChanged; @override Widget build(BuildContext context) { return TextFormField( controller: controller, onChanged: onChanged, keyboardType: const TextInputType.numberWithOptions(decimal: true), decoration: InputDecoration( labelText: context.tr('Amount'), prefixText: '$currency ', ), validator: (value) { final parsed = double.tryParse((value ?? '').replaceAll(',', '.')); if (parsed == null || parsed <= 0) { return context.tr('Enter an amount'); } return null; }, ); }} class _SelectField extends StatelessWidget { const _SelectField({ required this.label, required this.value, required this.options, required this.onChanged, }); final String label; final String value; final List<String> options; final ValueChanged<String> onChanged; @override Widget build(BuildContext context) { return DropdownButtonFormField<String>( initialValue: value, isExpanded: true, decoration: InputDecoration(labelText: label), items: [ for (final option in options) DropdownMenuItem( value: option, child: Text( option.replaceAll('_', ' '), overflow: TextOverflow.ellipsis, ), ), ], validator: _required, onChanged: (value) { if (value != null) { onChanged(value); } }, ); }} String? _required(String? value) { if (value == null || value.trim().isEmpty) { return 'Required'; } return null;} String _payeeLabel(Payee payee) { return fallbackText(payee.name, 'Payee');} String _payeeSubtitle(Payee payee) { final parts = [ if (payee.accountReference.trim().isNotEmpty) payee.accountReference.trim(), if (payee.currency.trim().isNotEmpty) payee.currency.trim(), if (payee.paymentMethod.trim().isNotEmpty) payee.paymentMethod.trim(), if (payee.provider.trim().isNotEmpty) payee.provider.trim(), if (payee.requiresConfirmation) 'confirmation required', ]; return fallbackText(parts.join(' • '), 'Account unavailable');} /// Resolve decorative hues without changing the inherited theme's defaults.Color _decorationColor(BuildContext context, String key, Color fallback) => context.brandDesign .color(Theme.of(context).brightness, key, fallback: fallback);