โ† Code knowledge base
Reviewed Flutter implementation ยท 2026-10-27

card_detail_screen.dart

mobile_flutter/lib/features/cards/presentation/card_detail_screen.dart

Immutable source snapshot based on revision de78e63ed65c. 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โ€“4243

import 'card_auto_top_up_sheet.dart';import '../../../shared/widgets/refresh_action.dart';import 'package:mobile_flutter/core/l10n/app_localizations.dart';import 'dart:async';import 'dart:convert';import 'dart:math' as math; import 'package:flutter/foundation.dart';import 'package:flutter/material.dart';import 'package:flutter_riverpod/flutter_riverpod.dart';import 'package:go_router/go_router.dart';import 'package:intl/intl.dart';import 'package:webview_flutter/webview_flutter.dart'; import '../../../app/routes.dart';import '../../../brands/cardeo/cardeo.dart';import '../../../core/branding/app_design.dart';import '../../../brands/cardeo/cardeo_tilt.dart';import '../../../core/api/dio_provider.dart';import '../../../core/models/banking_models.dart';import '../../../core/models/group_card_fees.dart';import '../../../core/models/platform_models.dart';import '../../../core/widgets/app_states.dart';import '../../../shared/shared.dart';import '../../../shared/widgets/app_progress_indicator.dart';import '../../auth/application/biometric_providers.dart';import '../../auth/data/biometric_authenticator.dart';import '../../banking/application/banking_providers.dart';import '../../platform/application/platform_providers.dart';import '../../transactions/domain/transaction_identity.dart';import '../../wallets/data/wallet_providers.dart';import '../../wallets/domain/wallet_models.dart';import '../domain/card_transaction_activity.dart';import 'add_to_wallet_sheet.dart';import '../domain/card_top_up_balance.dart';import 'card_transactions_screen.dart' show cardActivityIsMoneyIn;import 'widgets/secure_card_frame.dart';import '../domain/card_control_capabilities.dart';import 'widgets/card_face.dart';import 'widgets/card_ledger.dart';import 'widgets/neo_bank_card.dart';import 'set_card_pin_sheet.dart';import 'card_limits_sheet.dart';import '../domain/card_limits.dart'; class CardDetailScreen extends ConsumerStatefulWidget {  const CardDetailScreen({    required this.cardId,    this.asTab = false,    super.key,  });   final String cardId;   /// Hosted by the Cards tab: no back arrow, "Cards" title, and the hero  /// card swipes between the customer's cards.  final bool asTab;   @override  ConsumerState<CardDetailScreen> createState() => _CardDetailScreenState();} class _CardDetailScreenState extends ConsumerState<CardDetailScreen> {  CardStatus? _localStatus;   /// The auto-freeze switch as the customer last set it, shown ahead of the  /// server's answer so the toggle moves under the finger; dropped on failure  /// and when another card is selected.  bool? _autoFreezeOverride;  bool _autoFreezeBusy = false;  String? _secureCardWidgetUrl;  bool _secureCardLoading = false;   /// True from the tap on Manage balance until its dialog has closed. The  /// dialog only appears once the balances have been re-fetched, and every  /// tap that landed during that wait used to open a copy of its own behind  /// the first, so a customer who pressed twice on a slow link had two or  /// three sheets to close after one top-up.  bool _balanceDialogOpen = false;  int _secureRequestId = 0;  bool _secureRecoveryAttempted = false;  Future<void>? _refreshInFlight;   /// Card currently shown; changes when the hero is swiped.  late String _cardId = widget.cardId;  PageController? _heroController;   @override  void didUpdateWidget(covariant CardDetailScreen oldWidget) {    super.didUpdateWidget(oldWidget);    if (oldWidget.cardId == widget.cardId) return;    _selectCard(widget.cardId);  }   @override  void dispose() {    _heroController?.dispose();    super.dispose();  }   final _prefetched = <String>{};   /// Loads detail, controls, limits and activity for the cards either side  /// of the one in view. Providers are cached per card, so the data is  /// already there when the customer swipes.  void _prefetchNeighbours(List<PaymentCard>? cards) {    if (cards == null) return;    final open =        cards.where((item) => item.status != CardStatus.cancelled).toList();    final index = open.indexWhere((item) => item.id == _cardId);    if (index < 0) return;    for (final neighbour in [index - 1, index + 1]) {      if (neighbour < 0 || neighbour >= open.length) continue;      final id = open[neighbour].id;      if (!_prefetched.add(id)) continue;      WidgetsBinding.instance.addPostFrameCallback((_) {        if (!mounted) return;        for (final future in <Future<Object?>>[          ref.read(cardDetailProvider(id).future),          ref.read(cardControlCapabilitiesProvider(id).future),          ref.read(cardLimitsProvider(id).future),          ref.read(cardTransactionsProvider(id).future),        ]) {          unawaited(future.then<void>((_) {}, onError: (Object _) {}));        }      });    }  }   void _selectCard(String cardId) {    if (cardId == _cardId) return;    setState(() {      _cardId = cardId;      _localStatus = null;      _autoFreezeOverride = null;      _autoFreezeBusy = false;      _secureRequestId++;      _secureRecoveryAttempted = false;      _secureCardWidgetUrl = null;      _secureCardLoading = false;    });  }   void _showSnackBar(BuildContext context, String message) {    WidgetsBinding.instance.addPostFrameCallback((_) {      if (!context.mounted) {        return;      }      ScaffoldMessenger.of(context).showSnackBar(        SnackBar(content: Text(message)),      );    });  }   @override  Widget build(BuildContext context) {    final isCardeo = ref.watch(appConfigProvider).branding.isCardeo;    final cardDetail = ref.watch(cardDetailProvider(_cardId));    final cardList = ref.watch(cardsProvider);    final cardActivity = ref.watch(cardTransactionsProvider(_cardId));    final cardControls = ref.watch(cardControlCapabilitiesProvider(_cardId));    final actionState = ref.watch(bankingActionControllerProvider);    final platformActionState = ref.watch(platformActionControllerProvider);     // Only react to loading states triggered by an action; the notifier's    // first build also passes through loading and must stay silent.    bool wasRunning(AsyncValue<Object?>? previous) =>        previous?.isLoading == true && previous?.hasValue == true;    ref.listen(bankingActionControllerProvider, (previous, next) {      next.whenOrNull(        data: (_) {          if (wasRunning(previous)) {            _showSnackBar(context, 'Card updated');          }        },        error: (error, stackTrace) {          if (wasRunning(previous)) {            _showSnackBar(context, friendlyErrorMessage(error));          }        },      );    });    ref.listen(platformActionControllerProvider, (previous, next) {      next.whenOrNull(        data: (result) {          if (!wasRunning(previous) || result == null) {            return;          }          _showSnackBar(context, result.message);        },        error: (error, stackTrace) {          if (wasRunning(previous)) {            _showSnackBar(context, friendlyErrorMessage(error));          }        },      );    });     // The list summary keeps paging responsive while full detail refreshes.    final listed =        cardList.valueOrNull?.where((item) => item.id == _cardId).firstOrNull;    final effectiveDetail =        cardDetail.isLoading && !cardDetail.hasValue && listed != null            ? AsyncData<PaymentCard>(listed)            : cardDetail;    if (widget.asTab) _prefetchNeighbours(cardList.valueOrNull);    return _SheenLayer(      enabled: isCardeo,      child: effectiveDetail.when(        data: (card) {          CardStatus? listedStatus;          for (final listedCard              in cardList.valueOrNull ?? const <PaymentCard>[]) {            if (listedCard.id == _cardId) {              listedStatus = listedCard.status;              break;            }          }          final status = _localStatus ?? listedStatus ?? card.status;          final limitControls = cardControls.valueOrNull;          final limitsInfo = ref.watch(cardLimitsProvider(_cardId)).valueOrNull;          final canSetLimits = limitsInfo?.canUpdate == true ||              limitControls?.canUpdateLimits == true;          // Two columns need room for the 400pt card column plus details.          final desktop = isCardeo && MediaQuery.sizeOf(context).width >= 1180;          final secureRequest = _secureRequestId;          final stage = _SecureCardStage(            card: card,            status: status,            isCardeo: isCardeo,            secureUrl: _secureCardWidgetUrl,            loading: _secureCardLoading,            onReady: () {              if (mounted && secureRequest == _secureRequestId) {                setState(() => _secureCardLoading = false);              }            },            onRefresh: () {              if (mounted && secureRequest == _secureRequestId) {                unawaited(_requestSecureCardSession(card));              }            },            onError: (message) {              if (!mounted || secureRequest != _secureRequestId) return;              if (!_secureRecoveryAttempted) {                _secureRecoveryAttempted = true;                unawaited(_requestSecureCardSession(card, recovery: true));              } else {                _hideSecureCardDetails();                _showSnackBar(context,                    'Secure card details could not be loaded. Please try revealing them again.');              }            },          );          // On the Cards tab the hero swipes between open cards; every other          // section below follows the card in view.          final swipeCards = widget.asTab && isCardeo && !desktop              ? (cardList.valueOrNull ?? const <PaymentCard>[])                  .where((item) => item.status != CardStatus.cancelled)                  .toList()              : const <PaymentCard>[];          final heroSection = <Widget>[            if (swipeCards.length > 1)              _HeroCarousel(                cards: swipeCards,                selectedId: _cardId,                controller: _heroController ??= PageController(                  initialPage: math.max(                    0,                    swipeCards.indexWhere((item) => item.id == _cardId),                  ),                ),                stage: stage,                revealed: _secureCardWidgetUrl != null && !_secureCardLoading,                onHideSecure: _hideSecureCardDetails,                onSelected: (item) => _selectCard(item.id),              )            else              stage,            if (card.isEqualsMoney) const SafeguardingStatementButton(),          ];          // No push provisioning yet: on a phone the card is typed into the          // Wallet app by hand, so the page says how. Anywhere else the row          // would be a promise the device cannot keep, so it is absent.          final wallet = mobileWalletForDevice();          final Widget? walletTile =              status == CardStatus.active && wallet != null                  ? _ControlTile(                      enabled: true,                      icon: Icons.wallet_rounded,                      title: context.tr(wallet.rowTitle),                      subtitle: context.tr(wallet.rowSubtitle),                      cardeoSubtitle: context.tr(wallet.rowSubtitleShort),                      onTap: () => showAddToWalletSheet(                        context,                        wallet: wallet,                        card: card,                        onShowCardDetails: _secureCardWidgetUrl != null                            ? null                            : () => _showSecureCardDetails(context, ref, card),                      ),                    )                  : null;          final actionSection = <Widget>[            SizedBox(height: isCardeo ? AppSpacing.md : 16),            _ActionGrid(              isCardeo: isCardeo,              isBusy: actionState.isLoading ||                  platformActionState.isLoading ||                  _secureCardLoading ||                  _balanceDialogOpen,              frozen: status == CardStatus.frozen,              secureVisible:                  _secureCardWidgetUrl != null && !_secureCardLoading,              onTopUp: () => _openBalanceDialog(context, ref, card),              onToggleFreeze: () => _toggleFreeze(context, ref, card, status),              onTransactions: () =>                  context.go('/cards/${card.id}/transactions'),              onSecureDetails: () => _secureCardWidgetUrl != null                  ? _hideSecureCardDetails()                  : _showSecureCardDetails(context, ref, card),              canUpdateLimits: canSetLimits,              onLimits: canSetLimits                  ? () => showCardLimitsSheet(context, ref, card: card)                  : null,            ),          ];          // The same row again, straight under the controls: a customer who          // wants to pay with the phone should not have to scroll to Card          // controls to find out how. It still lives there too.          final walletSection = <Widget>[            if (walletTile != null) ...[              SizedBox(height: isCardeo ? AppSpacing.md : 16),              if (isCardeo)                CardeoListGroup(children: [walletTile])              else                walletTile,              const WalletBalanceNote(),            ],          ];          final moneyBusy = actionState.isLoading ||              platformActionState.isLoading ||              _balanceDialogOpen;          final canMoveMoney = status != CardStatus.frozen && !moneyBusy;          final balanceSection = <Widget>[            if (isCardeo) ...[              const SizedBox(height: AppSpacing.md),              _CardBalanceBlock(card: card),              const SizedBox(height: AppSpacing.md),              // The one decisive action on the page. Everything else here is              // a control, a fact or a list row, so the glass stays rare              // enough to mean something.              CardeoGlassButton(                label: context.tr('Manage balance'),                icon: Icons.add_rounded,                loading: moneyBusy,                onPressed: canMoveMoney                    ? () => _openBalanceDialog(context, ref, card)                    : null,                semanticsLabel: status == CardStatus.frozen                    ? context.tr(                        'Manage balance, unavailable while this card is frozen')                    : null,              ),              if (status == CardStatus.frozen) ...[                const SizedBox(height: AppSpacing.xs),                Text(                  context.tr('Unfreeze this card to move money.'),                  textAlign: TextAlign.center,                  style: Theme.of(context).textTheme.bodySmall?.copyWith(                        color: CardeoInk.secondary(context),                      ),                ),              ],            ],          ];          // The card's own facts. Desktop used to be the only place these          // rendered, which left a phone with no way to read the card's          // number, type or currency at all.          List<Widget> detailsSection({bool leading = false}) => isCardeo              ? [                  if (!leading) const SizedBox(height: AppSpacing.lg),                  _CardeoCardDetailsPanel(                    card: card,                    status: status,                    controls: limitControls,                    limits: limitsInfo,                  ),                ]              : [                  if (card.discountCode.isNotEmpty) ...[                    const SizedBox(height: 16),                    NeoSurfaceCard(                        child: ListTile(                      leading: const Icon(Icons.local_offer_outlined),                      title: Text(context.tr('Discount code')),                      subtitle: Column(                        crossAxisAlignment: CrossAxisAlignment.start,                        children: [                          Text(card.discountCode),                          Text(context.tr('Applied when ordering this card')),                        ],                      ),                    )),                  ],                ];          // A titled run of rows. Cardeo puts them on one surface with hairline          // dividers; every other brand keeps the stack of NeoSurfaceCards it          // renders today.          List<Widget> section(String title, List<Widget> tiles) => isCardeo              ? [                  const SizedBox(height: AppSpacing.lg),                  CardeoSectionTitle(title: title),                  const SizedBox(height: AppSpacing.xs),                  CardeoListGroup(children: tiles),                ]              : [                  const SizedBox(height: 16),                  _SectionTitle(title),                  const SizedBox(height: 8),                  ...tiles,                ];          final activitySection = <Widget>[            SizedBox(height: isCardeo ? AppSpacing.lg : 16),            if (isCardeo)              CardeoSectionTitle(                title: context.tr('Recent transactions'),                action: context.tr('View all'),                onAction: () => context.go('/cards/${card.id}/transactions'),              )            else              const _SectionTitle('Recent card activity'),            SizedBox(height: isCardeo ? AppSpacing.xs : 8),            cardActivity.when(              data: (items) {                // The same grouping as the full card ledger: a fee rides on                // the purchase it charged instead of taking its own row.                final grouped = {                  for (final row in groupCardFees([                    for (final resource in items)                      LedgerTransaction.fromJson(                          {...resource.metadata, 'id': resource.id}),                  ]))                    row.id: row                };                final activity = [                  for (final resource in items)                    if (grouped[resource.id] case final ledger?)                      (                        item: CardTransactionActivity.fromResource(resource),                        timeLabel: _cardPreviewTime(resource),                        ledger: ledger,                      ),                ].take(isCardeo ? 4 : 3).toList();                if (items.isEmpty) {                  return const _EmptyActivityPanel();                }                final identity = transactionCardIdentityLabel(card.id, [card]);                void open(CardTransactionActivity item) => context.go(                      '/transactions/${Uri.encodeComponent(item.reference)}'                      '?cardId=${Uri.encodeComponent(_cardId)}',                    );                if (isCardeo) {                  return CardeoListGroup(                    children: [                      for (final item in activity)                        _CardeoActivityRow(                          item: item.item,                          timeLabel: item.timeLabel,                          ledger: item.ledger,                          identityLabel: identity,                          onTap: () => open(item.item),                        ),                    ],                  );                }                return NeoGroupedCard(                  children: [                    for (final item in activity)                      _ActivityTile(                        item: item.item,                        timeLabel: item.timeLabel,                        ledger: item.ledger,                        identityLabel: identity,                        onTap: () => open(item.item),                      ),                  ],                );              },              error: (error, stackTrace) => isCardeo                  ? CardeoErrorState(                      compact: true,                      title: context.tr('Activity did not load'),                      error: error,                      onRetry: () =>                          ref.invalidate(cardTransactionsProvider(_cardId)),                    )                  : ErrorState(                      error: error,                      onRetry: () =>                          ref.invalidate(cardTransactionsProvider(_cardId)),                    ),              loading: () => isCardeo                  ? const _ActivitySkeleton()                  : LoadingState(label: context.tr('Loading card activity')),            ),          ];          // Both of these opened the identical dialog and produced the          // identical result, which is a false choice rather than two          // controls. On Cardeo the load direction is now the glass CTA above          // the section, so only the other direction is left here.          final autoReloadTiles = <Widget>[            if (isCardeo &&                !card.isEqualsMoney &&                card.currency.toUpperCase() == 'USD')              _ControlTile(                enabled: !moneyBusy,                icon: Icons.autorenew_rounded,                title: context.tr('Auto-Reload'),                subtitle: context.tr('Manage automatic card top-ups'),                onTap: () => showCardAutoTopUp(context, card),              ),          ];          final autoReloadSection = autoReloadTiles.isEmpty              ? const <Widget>[]              : section('Automatic funding', autoReloadTiles);          final movementTiles = <Widget>[            if (isCardeo)              _ControlTile(                enabled: !moneyBusy,                icon: Icons.north_east_rounded,                title: context.tr('Move money off card'),                subtitle: context.tr('Back to your account'),                onTap: () => _openBalanceDialog(                  context,                  ref,                  card,                  action: _CardBalanceAction.unload,                ),              )            else ...[              _ControlTile(                enabled: !actionState.isLoading && !_balanceDialogOpen,                icon: Icons.add_card_outlined,                title: context.tr('Load card'),                subtitle: context.tr('Move money onto this card'),                onTap: () => _openBalanceDialog(context, ref, card),              ),              _ControlTile(                enabled: !platformActionState.isLoading && !_balanceDialogOpen,                icon: Icons.remove_circle_outline,                title: context.tr('Unload card'),                subtitle: context.tr('Move money back to balance'),                onTap: () => _openBalanceDialog(context, ref, card),              ),            ],          ];          final movementSection = status == CardStatus.frozen              ? const <Widget>[]              : section('Money movement', movementTiles);          final controlTiles = <Widget>[            if (!card.virtual) ...[              _ControlTile(                enabled: !platformActionState.isLoading,                icon: Icons.verified_outlined,                title: context.tr('Activate card'),                subtitle: context.tr('Complete activation after delivery'),                cardeoSubtitle: 'After it arrives',                onTap: () => _runConfirmedPlatformAction(                  context,                  ref,                  title: context.tr('Activate card?'),                  message: context.tr(                      'Activate only after the cardholder has received and verified this card.'),                  confirmLabel: context.tr('Activate'),                  action: (api) => api.activateCard(card.id),                ),              ),            ],            if (card.canSetPin)              _ControlTile(                enabled: !platformActionState.isLoading,                icon: Icons.pin_outlined,                title: context.tr('Set PIN'),                subtitle: context.tr('Choose a 6-digit PIN for this card'),                onTap: () => _setPin(context, ref, card),              ),            _ControlTile(              enabled: !platformActionState.isLoading,              icon: Icons.visibility_outlined,              title: _secureCardWidgetUrl == null                  ? context.tr('Show secure card data')                  : context.tr('Hide secure card data'),              subtitle:                  context.tr('View card number, expiry date and security code'),              cardeoSubtitle: 'Number, expiry and CVV',              onTap: () => _secureCardWidgetUrl != null                  ? _hideSecureCardDetails()                  : _showSecureCardDetails(context, ref, card),            ),            if (walletTile != null) walletTile,            if (canSetLimits)              _ControlTile(                enabled: !platformActionState.isLoading,                icon: Icons.tune_rounded,                title: context.tr('Spending limits'),                subtitle: _limitsSummary(limitsInfo),                onTap: () => showCardLimitsSheet(context, ref, card: card),              ),            // Its own row, not a line inside Spending limits: the switch is            // the whole setting, so it sits where the eye can find it. The            // issuer does the freezing (ten minutes after an unfreeze); this            // only forwards on/off. It does not wait for the capabilities            // call either: a failed lookup leaves it reading "off" until the            // issuer answers.            _ControlTile(              enabled: !_autoFreezeBusy,              icon: Icons.shield_outlined,              title: context.tr('Auto freeze'),              subtitle: context                  .tr('Freeze this card automatically again after 10 minutes'),              cardeoSubtitle: context.tr('Freezes again after 10 minutes'),              onTap: null,              trailing: _ControlSwitch(                value: _autoFreezeOverride ??                    cardControls.valueOrNull?.autoFreezeEnabled ??                    false,                semanticsLabel: context.tr('Auto freeze'),                onChanged: _autoFreezeBusy                    ? null                    : (value) => _setAutoFreeze(ref, card, value),              ),            ),            ...cardControls.maybeWhen(              data: (controls) => [                if (controls.canMerchantLock)                  _ControlTile(                    enabled: true,                    icon: Icons.storefront_outlined,                    title: controls.isMerchantLocked                        ? context.tr('Merchant locked')                        : context.tr('Merchant lock available'),                    subtitle: controls.isMerchantLocked                        ? (controls.lockedMerchantName.isEmpty                            ? context.tr(                                'This card is restricted to its assigned merchant.')                            : context.tr('Restricted to {p0}.',                                {'p0': controls.lockedMerchantName}))                        : context.tr(                            'Available for this card product; assignment is managed by the card issuer.'),                    cardeoSubtitle: controls.isMerchantLocked                        ? (controls.lockedMerchantName.isEmpty                            ? 'Locked to one merchant'                            : 'Locked to ${controls.lockedMerchantName}')                        : 'Managed by the issuer',                    onTap: null,                  ),                if (controls.canControlOnlinePayments ||                    controls.canControlContactless ||                    controls.canControlAtm ||                    controls.canControlInternational)                  _ControlTile(                    enabled: true,                    icon: Icons.admin_panel_settings_outlined,                    title: context.tr('Advanced controls'),                    subtitle: _advancedControlSummary(controls),                    cardeoSubtitle: _advancedControlSummaryShort(controls),                    onTap: null,                  ),              ],              orElse: () => const <Widget>[],            ),            _ControlTile(              enabled: !platformActionState.isLoading,              icon: Icons.cancel_outlined,              title: context.tr('Cancel card'),              subtitle: context.tr('Permanently close this card'),              destructive: true,              onTap: () => _runConfirmedPlatformAction(                context,                ref,                title: context.tr('Cancel card?'),                message: context.tr(                    'This permanently closes the card and cannot be undone.'),                confirmLabel: context.tr('Cancel card'),                action: (api) => api.cancelCard(card.id),                destructive: true,              ),            ),          ];          final controlSection = section('Card controls', controlTiles);          return Scaffold(            appBar: AppBar(              centerTitle: isCardeo && !desktop,              automaticallyImplyLeading: !widget.asTab,              leading: isCardeo && !desktop && !widget.asTab                  ? IconButton(                      tooltip: context.tr('Back'),                      onPressed: () => context.canPop()                          ? context.pop()                          : context.go(AppRoutes.cards),                      icon: const Icon(Icons.arrow_back_ios_new_rounded,                          size: 19),                    )                  : null,              title: Text(widget.asTab                  ? context.tr('Cards')                  : isCardeo                      ? context.tr('My Card')                      : context.tr('Card controls')),              actions: [                RefreshAction(onRefresh: () => _refreshCard(card)),                if (widget.asTab)                  TextButton(                    onPressed: () => context.go(AppRoutes.orderCard),                    child: Text(context.tr('Order card')),                  ),                if (isCardeo && !desktop) ...[                  IconButton(                    tooltip: context.tr('Settings'),                    onPressed: () => context.go(AppRoutes.profile),                    icon: const Icon(Icons.settings_outlined),                  ),                  const SizedBox(width: 6),                ],              ],            ),            body: CardeoBackdrop(              child: RefreshIndicator(                onRefresh: () => _refreshCard(card),                child: desktop                    ? ListView(                        padding: const EdgeInsets.fromLTRB(40, 28, 40, 40),                        children: [                          Row(                            crossAxisAlignment: CrossAxisAlignment.start,                            children: [                              SizedBox(                                width: 400,                                child: Column(                                  crossAxisAlignment:                                      CrossAxisAlignment.stretch,                                  children: [                                    ...heroSection,                                    ...actionSection,                                    ...walletSection,                                    ...balanceSection,                                    ...autoReloadSection,                                    ...movementSection,                                  ],                                ),                              ),                              const SizedBox(width: 24),                              Expanded(                                child: Column(                                  crossAxisAlignment:                                      CrossAxisAlignment.stretch,                                  children: [                                    ...detailsSection(leading: true),                                    ...activitySection,                                    ...controlSection,                                  ],                                ),                              ),                            ],                          ),                        ],                      )                    : ListView(                        padding: EdgeInsets.fromLTRB(                          isCardeo ? 20 : 16,                          isCardeo ? 6 : 12,                          isCardeo ? 20 : 16,                          isCardeo ? 28 : 110,                        ),                        children: [                          ...heroSection,                          ...actionSection,                          ...walletSection,                          ...balanceSection,                          ...activitySection,                          ...detailsSection(),                          ...autoReloadSection,                          ...movementSection,                          ...controlSection,                        ],                      ),              ),            ),          );        },        error: (error, stackTrace) => Scaffold(          appBar: AppBar(              title:                  Text(isCardeo ? context.tr('My Card') : context.tr('Card'))),          body: isCardeo              ? CardeoBackdrop(                  child: CardeoErrorState(                    title: context.tr('Card did not load'),                    error: error,                    onRetry: () => ref.invalidate(cardDetailProvider(_cardId)),                    secondaryLabel: 'Back to cards',                    onSecondary: () => context.go(AppRoutes.cards),                  ),                )              : ErrorState(                  error: error,                  onRetry: () => ref.invalidate(cardDetailProvider(_cardId)),                ),        ),        loading: () => Scaffold(          appBar: AppBar(              title:                  Text(isCardeo ? context.tr('My Card') : context.tr('Card'))),          body: isCardeo              ? const CardeoBackdrop(child: _CardDetailSkeleton())              : LoadingState(label: context.tr('Loading card')),        ),      ),    );  }   /// Every route into Manage balance on this screen goes through here so  /// the second tap of a double tap is swallowed rather than queued.  Future<void> _openBalanceDialog(    BuildContext context,    WidgetRef ref,    PaymentCard card, {    _CardBalanceAction action = _CardBalanceAction.load,  }) async {    if (_balanceDialogOpen) return;    setState(() => _balanceDialogOpen = true);    try {      await _showTopUpDialog(context, ref, card, action: action);    } finally {      if (mounted) setState(() => _balanceDialogOpen = false);    }  }   Future<void> _toggleFreeze(    BuildContext context,    WidgetRef ref,    PaymentCard card,    CardStatus status,  ) async {    final shouldFreeze = status != CardStatus.frozen;    final confirmed = await _confirmCardAction(      context,      title: shouldFreeze          ? context.tr('Freeze card?')          : context.tr('Unfreeze card?'),      message: shouldFreeze          ? context.tr('Card payments will be blocked until you unfreeze it.')          : context.tr('Card payments will be allowed again.'),      confirmLabel:          shouldFreeze ? context.tr('Freeze') : context.tr('Unfreeze'),    );    if (!confirmed) {      return;    }    if (!context.mounted) {      return;    }     setState(() {      _localStatus = shouldFreeze ? CardStatus.frozen : CardStatus.active;    });    await ref        .read(bankingActionControllerProvider.notifier)        .freezeCard(card.id, shouldFreeze);  }   Future<void> _setAutoFreeze(    WidgetRef ref,    PaymentCard card,    bool enabled,  ) async {    setState(() {      _autoFreezeOverride = enabled;      _autoFreezeBusy = true;    });    await ref        .read(platformActionControllerProvider.notifier)        .run((api) => api.setCardAutoFreeze(card.id, enabled));    if (!mounted) return;    final failed = ref.read(platformActionControllerProvider).hasError;    ref.invalidate(cardControlCapabilitiesProvider(card.id));    setState(() {      _autoFreezeBusy = false;      if (failed) _autoFreezeOverride = null;    });  }   Future<void> _setPin(    BuildContext context,    WidgetRef ref,    PaymentCard card,  ) async {    final pin = await showSetCardPinSheet(context, card);    if (pin == null || !context.mounted) return;    ref        .read(platformActionControllerProvider.notifier)        .run((api) => api.setCardPin(card.id, pin));  }   Future<void> _runConfirmedPlatformAction(    BuildContext context,    WidgetRef ref, {    required String title,    required String message,    required String confirmLabel,    required Future<ActionResult> Function(dynamic api) action,    bool destructive = false,  }) async {    final confirmed = await _confirmCardAction(      context,      title: title,      message: message,      confirmLabel: confirmLabel,      destructive: destructive,    );    if (!confirmed) {      return;    }     await ref.read(platformActionControllerProvider.notifier).run(action);  }   Future<void> _showSecureCardDetails(    BuildContext context,    WidgetRef ref,    PaymentCard card,  ) async {    if (kIsWeb) {      // Browsers only prompt when the user enrolled a passkey from Settings;      // the issuer widget is a single-use session that renders in the card.      BiometricEnrollment? enrollment;      try {        enrollment = await ref.read(biometricEnrollmentProvider.future);      } catch (_) {        enrollment = null;      }      if (!context.mounted || card.id != _cardId) return;      if (enrollment != null && enrollment.enabled && enrollment.hasToken) {        final gate =            await ref.read(biometricAuthenticatorProvider).authenticate(                  reason: 'Confirm your identity to reveal secure card details',                );        if (!context.mounted || card.id != _cardId) return;        if (gate != BiometricAuthResult.success) {          ScaffoldMessenger.of(context).showSnackBar(            SnackBar(              content:                  Text(context.tr('Identity confirmation was not completed.')),            ),          );          return;        }      }      await _requestSecureCardSession(card);      return;    }    final capability =        await ref.read(biometricAuthenticatorProvider).capability();    if (!context.mounted || card.id != _cardId) return;    if (!capability.available) {      ScaffoldMessenger.of(context).showSnackBar(        SnackBar(          content: Text(            context.tr(                'Set up Face ID or fingerprint on this device to reveal card details.'),          ),        ),      );      return;    }     final biometricResult =        await ref.read(biometricAuthenticatorProvider).authenticate(              reason: 'Confirm your identity to reveal secure card details',            );    if (!context.mounted || card.id != _cardId) return;    if (biometricResult != BiometricAuthResult.success) {      final message = biometricResult == BiometricAuthResult.lockedOut          ? context              .tr('Biometrics are locked. Unlock your device and try again.')          : context.tr('Identity confirmation was not completed.');      ScaffoldMessenger.of(context).showSnackBar(        SnackBar(content: Text(message)),      );      return;    }     await _requestSecureCardSession(card);  }   void _hideSecureCardDetails() {    setState(() {      _secureRequestId++;      _secureCardWidgetUrl = null;      _secureCardLoading = false;    });  }   Future<void> _requestSecureCardSession(PaymentCard card,      {bool recovery = false}) async {    if (!mounted || card.id != _cardId) return;    final requestId = ++_secureRequestId;    if (!recovery) _secureRecoveryAttempted = false;    setState(() {      // Session URLs are single-use: discard the old frame before asking      // Hoppa for a new one. Never reload a previously consumed URL.      _secureCardWidgetUrl = null;      _secureCardLoading = true;    });    try {      final result =          await ref.read(mobilePlatformApiProvider).getCardWidget(card.id);      if (!mounted || requestId != _secureRequestId || card.id != _cardId) {        return;      }      final rawUrl = _secureWidgetUrl(result.metadata);      if (rawUrl == null) {        throw Exception('The secure card widget is temporarily unavailable.');      }      setState(() => _secureCardWidgetUrl = _secureWidgetPresentationUrl(          rawUrl, card,          backgroundColor: Theme.of(context).scaffoldBackgroundColor));    } catch (error) {      if (!mounted || requestId != _secureRequestId) return;      setState(() => _secureCardLoading = false);      _showSnackBar(context, friendlyErrorMessage(error));    }  }   Future<void> _refreshCard(PaymentCard card) {    return _refreshInFlight ??= _performCardRefresh(card).whenComplete(() {      _refreshInFlight = null;    });  }   Future<void> _performCardRefresh(PaymentCard card) async {    final renewSecure = _secureCardWidgetUrl != null || _secureCardLoading;    final requestId = ++_secureRequestId;    setState(() {      _secureCardWidgetUrl = null;      _secureCardLoading = renewSecure;    });    try {      await Future.wait([        ref.refresh(cardDetailProvider(card.id).future),        ref.refresh(cardTransactionsProvider(card.id).future),        ref.refresh(cardControlCapabilitiesProvider(card.id).future),      ]);    } finally {      if (mounted &&          requestId == _secureRequestId &&          card.id == _cardId &&          renewSecure) {        await _requestSecureCardSession(card);      }    }  }   Future<bool> _confirmCardAction(    BuildContext context, {    required String title,    required String message,    required String confirmLabel,    bool destructive = false,  }) async {    return await showDialog<bool>(          context: context,          builder: (context) => AlertDialog(            title: Text(title),            content: Text(message),            actions: [              TextButton(                onPressed: () => Navigator.of(context).pop(false),                child: Text(context.tr('Back')),              ),              _DialogConfirm(                label: confirmLabel,                destructive: destructive,                onPressed: () => Navigator.of(context).pop(true),              ),            ],          ),        ) ??        false;  }} /// A dialog's confirming action.////// `AlertDialog.actions` is an `OverflowBar`, which lays its children out/// unbounded โ€” so the glass CTA hugs its label instead of expanding, and its/// height drops to the 44 pt target floor rather than the 54 pt a page CTA/// stands at. It also holds still: a dialog is already the moving object on/// screen while it is open.////// Every other brand keeps the `FilledButton` it renders today, including the/// `colorScheme.error` fill on a destructive confirm.class _DialogConfirm extends StatelessWidget {  const _DialogConfirm({    required this.label,    required this.onPressed,    this.destructive = false,  });   final String label;  final VoidCallback? onPressed;  final bool destructive;   @override  Widget build(BuildContext context) {    if (context.isCardeoTheme) {      return CardeoGlassButton(        label: label,        onPressed: onPressed,        tone: destructive            ? CardeoGlassButtonTone.danger            : CardeoGlassButtonTone.primary,        sheen: false,        expand: false,        height: 44,      );    }    return FilledButton(      style: destructive          ? FilledButton.styleFrom(              backgroundColor: Theme.of(context).colorScheme.error,            )          : null,      onPressed: onPressed,      child: Text(label),    );  }} enum _CardBalanceAction { load, unload } class _CardTopUpSubmission {  const _CardTopUpSubmission({required this.amount, required this.action});   final Money amount;  final _CardBalanceAction action;} class _BudgetTransferSubmission {  const _BudgetTransferSubmission({    required this.sourceBudgetId,    required this.amount,  });   final String sourceBudgetId;  final Money amount;} Future<_BudgetTransferSubmission?> _showBudgetTransferDialog(  BuildContext context, {  required PlatformResource targetBudget,  required List<PlatformResource> sourceBudgets,  required String fallbackCurrency,}) {  var sourceBudgetId = _budgetId(sourceBudgets.first);  var amountText = '';  var currency =      _sharedBudgetCurrencies(sourceBudgets.first, targetBudget).firstOrNull ??          fallbackCurrency;   return showDialog<_BudgetTransferSubmission>(    context: context,    builder: (context) => StatefulBuilder(      builder: (context, setDialogState) {        final selectedSource = sourceBudgets.firstWhere(          (budget) => _budgetId(budget) == sourceBudgetId,          orElse: () => sourceBudgets.first,        );        final currencies = _sharedBudgetCurrencies(          selectedSource,          targetBudget,        );        if (currencies.isNotEmpty && !currencies.contains(currency)) {          currency = currencies.first;        }        final amount = _parseMoney(amountText, currency);         return NeoFullScreenDialog(          title: context.tr('Top up {p0}', {'p0': _budgetTitle(targetBudget)}),          body: SingleChildScrollView(            padding: const EdgeInsets.all(20),            child: Column(              mainAxisSize: MainAxisSize.min,              children: [                DropdownButtonFormField<String>(                  initialValue: sourceBudgetId,                  decoration:                      InputDecoration(labelText: context.tr('Source budget')),                  items: [                    for (final budget in sourceBudgets)                      DropdownMenuItem(                        value: _budgetId(budget),                        child: Text(_budgetTitle(budget)),                      ),                  ],                  onChanged: (value) {                    if (value != null) {                      setDialogState(() => sourceBudgetId = value);                    }                  },                ),                const SizedBox(height: 12),                DropdownButtonFormField<String>(                  initialValue: currency,                  decoration:                      InputDecoration(labelText: context.tr('Currency')),                  items: [                    for (final option                        in (currencies.isEmpty ? [currency] : currencies))                      DropdownMenuItem(value: option, child: Text(option)),                  ],                  onChanged: (value) {                    if (value != null) {                      setDialogState(() => currency = value);                    }                  },                ),                const SizedBox(height: 12),                TextFormField(                  onChanged: (value) {                    setDialogState(() => amountText = value);                  },                  decoration: InputDecoration(labelText: context.tr('Amount')),                  keyboardType:                      const TextInputType.numberWithOptions(decimal: true),                ),              ],            ),          ),          primaryLabel: 'Transfer',          primaryIcon: Icons.arrow_forward_rounded,          onPrimary: amount.minorUnits <= 0              ? null              : () => Navigator.of(context).pop(                    _BudgetTransferSubmission(                      sourceBudgetId: sourceBudgetId,                      amount: amount,                    ),                  ),        );      },    ),  );} /// Smallest card load the issuer accepts, in minor units (10.00 USD).const kCardTopUpMinimumMinorUnits = 1000; class _CardTopUpDialog extends StatefulWidget {  const _CardTopUpDialog({    required this.card,    required this.currency,    required this.cardBalance,    required this.interlaceBalances,    required this.estimateTopUp,    this.initialAction = _CardBalanceAction.load,  });   /// The same card captured by the caller for the eventual load/unload.  final PaymentCard card;   /// The segment the dialog opens on.  final _CardBalanceAction initialAction;   final String currency;  final Money cardBalance;  final List<HoppaWalletAsset> interlaceBalances;  final Future<QuantumTopUpEstimate> Function(Money amount) estimateTopUp;   @override  State<_CardTopUpDialog> createState() => _CardTopUpDialogState();} class _CardTopUpDialogState extends State<_CardTopUpDialog> {  final TextEditingController _controller = TextEditingController();  late _CardBalanceAction _action;  int _estimateVersion = 0;  bool _loadingEstimate = false;  QuantumTopUpEstimate? _estimate;  String? _estimateError;   @override  void initState() {    super.initState();    _action = widget.initialAction;    _controller.addListener(_scheduleEstimate);  }   @override  void dispose() {    _estimateVersion++;    _controller      ..removeListener(_scheduleEstimate)      ..dispose();    super.dispose();  }   Money? get _amount {    final value = double.tryParse(_controller.text.replaceAll(',', '.'));    if (value == null || value <= 0) {      return null;    }    return Money(      currency: widget.currency,      minorUnits: (value * 100).round(),    );  }   void _scheduleEstimate() {    final version = ++_estimateVersion;    final amount = _amount;    if (amount == null || _action == _CardBalanceAction.unload) {      setState(() {        _loadingEstimate = false;        _estimate = null;        _estimateError = null;      });      return;    }     setState(() {      _loadingEstimate = true;      _estimateError = null;    });     Future<void>.delayed(const Duration(milliseconds: 500), () async {      if (!mounted || version != _estimateVersion) {        return;      }      try {        final estimate = await widget.estimateTopUp(amount);        if (!mounted || version != _estimateVersion) {          return;        }        setState(() {          _loadingEstimate = false;          _estimate = estimate;          _estimateError = estimate.success              ? null              : fallbackText(estimate.message ?? '', 'Failed to get estimate');        });      } catch (error) {        if (!mounted || version != _estimateVersion) {          return;        }        setState(() {          _loadingEstimate = false;          _estimate = null;          _estimateError = friendlyErrorMessage(error);        });      }    });  }   void _selectAction(_CardBalanceAction next) {    if (next == _action) return;    setState(() {      _action = next;      _estimateVersion++;      _loadingEstimate = false;      _estimate = null;      _estimateError = null;    });    _scheduleEstimate();  }   @override  Widget build(BuildContext context) {    final isCardeo = context.isCardeoTheme;    final amount = _amount;    final available = _action == _CardBalanceAction.load        ? cardTopUpAvailable(widget.interlaceBalances)        : widget.cardBalance.minorUnits / 100;    final exceedsBalance =        amount != null && amount.minorUnits / 100 > available;    // The card issuer only accepts loads of at least 10 USD.    final belowMinimum = _action == _CardBalanceAction.load &&        amount != null &&        amount.minorUnits > 0 &&        amount.minorUnits < kCardTopUpMinimumMinorUnits;    return NeoFullScreenDialog(      title: context.tr('Manage balance'),      body: SingleChildScrollView(        padding: const EdgeInsets.all(20),        child: Column(          mainAxisSize: MainAxisSize.min,          crossAxisAlignment: CrossAxisAlignment.start,          children: [            _ManagedCardIdentity(card: widget.card),            const SizedBox(height: 16),            if (isCardeo)              CardeoSegmentedControl<_CardBalanceAction>(                height: 44,                selected: _action,                onChanged: _selectAction,                segments: [                  (value: _CardBalanceAction.load, label: context.tr('Top up')),                  (                    value: _CardBalanceAction.unload,                    label: context.tr('Unload')                  ),                ],              )            else              SizedBox(                width: double.infinity,                child: SegmentedButton<_CardBalanceAction>(                  showSelectedIcon: false,                  segments: [                    ButtonSegment(                      value: _CardBalanceAction.load,                      icon: const Icon(Icons.add_card_outlined),                      label: Text(context.tr('Top up')),                    ),                    ButtonSegment(                      value: _CardBalanceAction.unload,                      icon: const Icon(Icons.remove_circle_outline),                      label: Text(context.tr('Unload')),                    ),                  ],                  selected: {_action},                  onSelectionChanged: (selection) =>                      _selectAction(selection.first),                ),              ),            const SizedBox(height: 16),            if (_action == _CardBalanceAction.load)              _CryptoCardBalancesPanel(balances: widget.interlaceBalances)            else              _BalanceSummary(                label: context.tr('Available on card'),                value: widget.cardBalance.formatted,                icon: Icons.credit_card_rounded,              ),            const SizedBox(height: 16),            TextField(              controller: _controller,              keyboardType:                  const TextInputType.numberWithOptions(decimal: true),              decoration: InputDecoration(                labelText: context.tr('Amount'),                prefixIcon: const Icon(Icons.payments_outlined),                suffixText: widget.currency,                helperText: _action == _CardBalanceAction.load                    ? context.tr(                        'Minimum load is {p0} 10.00', {'p0': widget.currency})                    : null,                errorText: exceedsBalance                    ? _action == _CardBalanceAction.load                        ? context.tr(                            'Amount exceeds the combined USD, USDT and USDC balance.')                        : context                            .tr('Amount exceeds the available card balance.')                    : belowMinimum                        ? context.tr('The minimum load is {p0} 10.00.',                            {'p0': widget.currency})                        : null,              ),            ),            const SizedBox(height: 12),            _BalancePercentageSelector(              available: available,              wholeDollarMax: _action == _CardBalanceAction.load,              onSelected: (value) => _writeAmount(_controller, value),            ),            // A dropdown with one option and `onChanged: null` is not a            // choice, and the currency it names is already the amount            // field's suffix. Cardeo drops it; every other brand keeps the            // field it has today.            if (!isCardeo) ...[              const SizedBox(height: 12),              DropdownButtonFormField<String>(                initialValue: 'USD',                decoration:                    InputDecoration(labelText: context.tr('Currency/Token')),                items: const [                  DropdownMenuItem(value: 'USD', child: Text('USD')),                ],                onChanged: null,              ),            ],            if (_action == _CardBalanceAction.load && _loadingEstimate) ...[              const SizedBox(height: 8),              Text(                context.tr('Calculating fees...'),                style: Theme.of(context).textTheme.bodySmall?.copyWith(                      color: isCardeo ? CardeoInk.secondary(context) : null,                    ),              ),            ],            if (_action == _CardBalanceAction.load &&                _estimate != null &&                _estimate!.success) ...[              const SizedBox(height: 12),              _CardTopUpEstimatePanel(                amount: amount,                estimate: _estimate!,              ),            ],            if (_action == _CardBalanceAction.load &&                _estimateError != null) ...[              const SizedBox(height: 12),              Text(                _estimateError!,                style: Theme.of(context).textTheme.bodySmall?.copyWith(                      color: isCardeo                          ? CardeoPalette.of(context).danger                          : Theme.of(context).colorScheme.error,                    ),              ),            ],          ],        ),      ),      primaryLabel:          _action == _CardBalanceAction.load ? 'Add to card' : 'Unload card',      primaryIcon: _action == _CardBalanceAction.load          ? Icons.add_rounded          : Icons.remove_rounded,      onPrimary:          amount == null || available <= 0 || exceedsBalance || belowMinimum              ? null              : () {                  FocusManager.instance.primaryFocus?.unfocus();                  Navigator.of(context).pop(                    _CardTopUpSubmission(amount: amount, action: _action),                  );                },    );  }} /// Identifies the card receiving or releasing money, independently of its/// artwork and the funding wallet. The number always comes from the provider.class _ManagedCardIdentity extends StatelessWidget {  const _ManagedCardIdentity({required this.card});   final PaymentCard card;   @override  Widget build(BuildContext context) {    final theme = Theme.of(context);    final last4 = card.last4.trim();    return Semantics(      label: context.tr('Manage balance for {p0}, {p1}', {        'p0': card.displayLabel,        'p1': cardMetaSemantics(card, context: context)      }),      excludeSemantics: true,      child: Column(        crossAxisAlignment: CrossAxisAlignment.start,        children: [          Text(            last4.isEmpty                ? context.tr('Selected card')                : context.tr('Card ending in {p0}', {'p0': last4}),            key: const ValueKey('managed-card-last4'),            style: theme.textTheme.titleMedium?.copyWith(              fontWeight: FontWeight.w700,              fontFeatures: const [FontFeature.tabularFigures()],            ),          ),          const SizedBox(height: 4),          Text(            card.displayLabel,            maxLines: 2,            overflow: TextOverflow.ellipsis,            style: theme.textTheme.bodyMedium?.copyWith(              color: context.isCardeoTheme                  ? CardeoInk.secondary(context)                  : theme.colorScheme.onSurfaceVariant,            ),          ),        ],      ),    );  }} class _BalancePercentageSelector extends StatelessWidget {  const _BalancePercentageSelector({    required this.available,    required this.onSelected,    this.wholeDollarMax = false,  });   final double available;  final bool wholeDollarMax;  final ValueChanged<double> onSelected;   @override  Widget build(BuildContext context) {    const options = <(String, double)>[      ('25%', .25),      ('50%', .50),      ('75%', .75),      ('MAX', 1),    ];    final enabled = available > 0;     if (context.isCardeoTheme) {      return Column(        crossAxisAlignment: CrossAxisAlignment.start,        children: [          Text(            context.tr('Quick amount'),            style: TextStyle(              fontSize: 12.5,              fontWeight: FontWeight.w600,              color: CardeoInk.secondary(context),            ),          ),          const SizedBox(height: AppSpacing.xs),          Row(            children: [              for (var index = 0; index < options.length; index++) ...[                if (index > 0) const SizedBox(width: AppSpacing.xs),                Expanded(                  child: _QuickAmountChip(                    label: options[index].$1,                    enabled: enabled,                    onTap: () => onSelected(                      cardBalanceQuickAmount(available, options[index].$2,                          wholeDollarMax: wholeDollarMax),                    ),                  ),                ),              ],            ],          ),        ],      );    }     return Column(      crossAxisAlignment: CrossAxisAlignment.start,      children: [        Text(          context.tr('Quick amount'),          style: Theme.of(context).textTheme.labelLarge,        ),        const SizedBox(height: 8),        Row(          children: [            for (var index = 0; index < options.length; index++) ...[              if (index > 0) const SizedBox(width: 8),              Expanded(                child: OutlinedButton(                  onPressed: enabled                      ? () => onSelected(                            cardBalanceQuickAmount(                              available,                              options[index].$2,                              wholeDollarMax: wholeDollarMax,                            ),                          )                      : null,                  style: OutlinedButton.styleFrom(                    padding: const EdgeInsets.symmetric(vertical: 10),                    minimumSize: const Size(0, 42),                  ),                  child: Text(options[index].$1),                ),              ),            ],          ],        ),      ],    );  }} /// One of the four quick-amount buttons, in the house's control shape.////// `OutlinedButton`'s 42 pt minimum sits under the 44 pt floor and its edge/// comes from the Material outline, so Cardeo draws its own: a level-1 pill/// with a [CardeoBorders.controlSideOf] boundary, which is the token that/// carries the 3:1 rule for something the user operates.class _QuickAmountChip extends StatelessWidget {  const _QuickAmountChip({    required this.label,    required this.enabled,    required this.onTap,  });   final String label;  final bool enabled;  final VoidCallback onTap;   @override  Widget build(BuildContext context) {    return CardeoPressable(      enabled: enabled,      onTap: onTap,      borderRadius: BorderRadius.circular(AppRadii.pill),      semanticsLabel: label == 'MAX'          ? context.tr('Use the whole available balance')          : context.tr(              'Set the amount to {p0} of the available balance', {'p0': label}),      child: Opacity(        opacity: enabled ? 1 : CardeoOpacity.disabled,        child: Container(          height: 44,          alignment: Alignment.center,          decoration: BoxDecoration(            color: CardeoSurface.of(context, 1),            borderRadius: BorderRadius.circular(AppRadii.pill),            border: Border.fromBorderSide(              CardeoBorders.controlSideOf(context),            ),          ),          child: Text(            label,            style: TextStyle(              fontSize: 13,              fontWeight: FontWeight.w600,              color: CardeoInk.primary(context),              fontFeatures: const [FontFeature.tabularFigures()],            ),          ),        ),      ),    );  }} void _writeAmount(TextEditingController controller, double value) {  final text = value.toStringAsFixed(2);  controller    ..text = text    ..selection = TextSelection.collapsed(offset: text.length);} class _CryptoCardBalancesPanel extends StatelessWidget {  const _CryptoCardBalancesPanel({required this.balances});   final List<HoppaWalletAsset> balances;   @override  Widget build(BuildContext context) {    final visible = [...balances]..sort((left, right) {        if (left.symbol == 'USD' && right.symbol != 'USD') return -1;        if (right.symbol == 'USD' && left.symbol != 'USD') return 1;        return left.symbol.compareTo(right.symbol);      });     if (context.isCardeoTheme) {      // A wrap of chips hides the one number that decides whether the      // transfer can happen. As rows, the symbols stack and the balances      // right-align into a tabular column you can compare down.      return Column(        crossAxisAlignment: CrossAxisAlignment.start,        children: [          CardeoListGroup(            title: context.tr('Crypto card balances'),            children: visible.isEmpty                ? [                    CardeoRow(                      title: context.tr('Balances are unavailable'),                      subtitle: context.tr('Try again in a moment'),                    ),                  ]                : [                    for (final balance in visible)                      CardeoRow(                        leading: CurrencyLogo(                          symbol: balance.symbol,                          size: 28,                          fallbackIcon: balance.symbol == 'USD'                              ? Icons.account_balance_wallet_outlined                              : Icons.currency_exchange_rounded,                        ),                        title: balance.symbol,                        subtitle: cardTopUpFundingCurrencies                                .contains(balance.symbol.toUpperCase())                            ? context.tr('Available for top-up')                            : null,                        trailing: CardeoRowValue(                          value: balance.amount.toStringAsFixed(2),                        ),                      ),                  ],          ),          const SizedBox(height: AppSpacing.xs),          Text(            context.tr(                'USD, USDT and USDC count toward your available top-up balance.'),            style: TextStyle(              fontSize: 11.5,              height: 1.35,              color: CardeoInk.tertiary(context),            ),          ),        ],      );    }     return Container(      width: double.infinity,      padding: const EdgeInsets.all(16),      decoration: BoxDecoration(        color: Theme.of(context).colorScheme.surfaceContainerHighest,        borderRadius: BorderRadius.circular(16),      ),      child: Column(        crossAxisAlignment: CrossAxisAlignment.start,        children: [          Text(            context.tr('Crypto card balances'),            style: Theme.of(context).textTheme.titleMedium,          ),          const SizedBox(height: 4),          Text(            context.tr(                'USD, USDT and USDC count toward your available top-up balance.'),            style: Theme.of(context).textTheme.bodySmall?.copyWith(                  color: Theme.of(context).colorScheme.onSurfaceVariant,                ),          ),          const SizedBox(height: 12),          if (visible.isEmpty)            Text(              context.tr('Balances are temporarily unavailable.'),              style: Theme.of(context).textTheme.bodyMedium,            )          else            Wrap(              spacing: 8,              runSpacing: 8,              children: [                for (final balance in visible)                  Chip(                    avatar: CurrencyLogo(                      symbol: balance.symbol,                      size: 20,                      fallbackIcon: balance.symbol == 'USD'                          ? Icons.account_balance_wallet_outlined                          : Icons.currency_exchange_rounded,                    ),                    label: Text(                      '${balance.amount.toStringAsFixed(2)} ${balance.symbol}',                    ),                  ),              ],            ),        ],      ),    );  }} class _BalanceSummary extends StatelessWidget {  const _BalanceSummary({    required this.label,    required this.value,    required this.icon,  });   final String label;  final String value;  final IconData icon;   @override  Widget build(BuildContext context) {    if (context.isCardeoTheme) {      return CardeoListGroup(        children: [          CardeoRow(            leading: CardeoIconTile(              icon: icon,              color: CardeoPalette.of(context).accent,            ),            title: label,            trailing: CardeoRowValue(value: value),          ),        ],      );    }     return Container(      width: double.infinity,      padding: const EdgeInsets.all(16),      decoration: BoxDecoration(        color: Theme.of(context).colorScheme.surfaceContainerHighest,        borderRadius: BorderRadius.circular(16),      ),      child: Row(        children: [          Icon(icon, color: Theme.of(context).colorScheme.primary),          const SizedBox(width: 12),          Expanded(            child: Text(label, style: Theme.of(context).textTheme.bodyMedium),          ),          Text(value, style: Theme.of(context).textTheme.titleMedium),        ],      ),    );  }} class _CardTopUpEstimatePanel extends StatelessWidget {  const _CardTopUpEstimatePanel({    required this.amount,    required this.estimate,  });   final Money? amount;  final QuantumTopUpEstimate estimate;   @override  Widget build(BuildContext context) {    final fee = estimate.topUpFee;    final feePercent = estimate.topUpFeePercent;    final amountMajor = (amount?.minorUnits ?? 0) / 100;    final amountAdded =        fee == null ? null : (amountMajor - fee).clamp(0, double.infinity);     if (context.isCardeoTheme) {      return Container(        width: double.infinity,        padding: const EdgeInsets.all(AppSpacing.md),        decoration: BoxDecoration(          color: CardeoSurface.of(context, 1),          borderRadius: BorderRadius.circular(AppRadii.md),          border: CardeoBorders.subtleOf(context),        ),        child: Column(          crossAxisAlignment: CrossAxisAlignment.start,          children: [            const _PanelLabel('Estimated top-up fees'),            const SizedBox(height: AppSpacing.xxs),            if (feePercent != null)              _TopUpQuoteDetail(                label: context.tr('Top-up fee'),                value: '${(feePercent * 100).toStringAsFixed(2)}%',              ),            if (fee != null)              _TopUpQuoteDetail(                label: context.tr('Fee amount'),                value: '\$${fee.toStringAsFixed(2)} USD',              ),            // The figure the whole panel exists to produce, so it is the            // one line that is allowed to be loud.            if (amountAdded != null)              _TopUpQuoteDetail(                label: context.tr('Added to card'),                value: '\$${amountAdded.toStringAsFixed(2)} USD',                emphasis: true,              ),            if (estimate.usdt != null || estimate.usdc != null) ...[              const SizedBox(height: AppSpacing.md),              const _PanelLabel('Exchange quotes'),              const SizedBox(height: AppSpacing.xs),              if (estimate.usdt != null)                _TopUpExchangeQuote(asset: 'USDT', quote: estimate.usdt!),              if (estimate.usdt != null && estimate.usdc != null)                const SizedBox(height: AppSpacing.xs),              if (estimate.usdc != null)                _TopUpExchangeQuote(asset: 'USDC', quote: estimate.usdc!),            ],          ],        ),      );    }     return Container(      width: double.infinity,      padding: const EdgeInsets.all(12),      decoration: BoxDecoration(        border: Border.all(color: Theme.of(context).dividerColor),        borderRadius: BorderRadius.circular(8),      ),      child: Column(        crossAxisAlignment: CrossAxisAlignment.start,        children: [          Text(            context.tr('Estimated top-up fees'),            style: Theme.of(context).textTheme.labelLarge,          ),          const SizedBox(height: 6),          if (feePercent != null)            Text(context.tr('Top-up fee: {p0}%',                {'p0': (feePercent * 100).toStringAsFixed(2)})),          if (fee != null)            Text(context                .tr('Fee amount: \${p0} USD', {'p0': fee.toStringAsFixed(2)})),          if (amountAdded != null)            Text(              context.tr('Amount added to card: \${p0} USD',                  {'p0': amountAdded.toStringAsFixed(2)}),              style: Theme.of(context).textTheme.bodyMedium?.copyWith(                    fontWeight: FontWeight.w700,                  ),            ),          if (estimate.usdt != null || estimate.usdc != null) ...[            const SizedBox(height: 14),            Text(              context.tr('Exchange quotes'),              style: Theme.of(context).textTheme.labelLarge,            ),            const SizedBox(height: 8),            if (estimate.usdt != null)              _TopUpExchangeQuote(asset: 'USDT', quote: estimate.usdt!),            if (estimate.usdt != null && estimate.usdc != null)              const SizedBox(height: 8),            if (estimate.usdc != null)              _TopUpExchangeQuote(asset: 'USDC', quote: estimate.usdc!),          ],        ],      ),    );  }} class _TopUpExchangeQuote extends StatelessWidget {  const _TopUpExchangeQuote({required this.asset, required this.quote});   final String asset;  final QuantumTopUpQuote quote;   @override  Widget build(BuildContext context) {    final baseCurrency = fallbackText(quote.baseCurrency, 'USD');    final quoteCurrency = fallbackText(quote.quoteCurrency, asset);    final receivedCurrency = fallbackText(quote.rfqCurrency, quoteCurrency);    final feeCurrency = fallbackText(quote.feeCurrency ?? '', quoteCurrency);     if (context.isCardeoTheme) {      return Container(        width: double.infinity,        padding: const EdgeInsets.all(AppSpacing.sm),        decoration: BoxDecoration(          // One level above the panel it sits in, which is how the system          // says "inside", without drawing a card inside a card.          color: CardeoSurface.of(context, 2),          borderRadius: BorderRadius.circular(AppRadii.sm),        ),        child: Column(          crossAxisAlignment: CrossAxisAlignment.start,          children: [            Row(              children: [                CurrencyLogo(symbol: asset, size: 22),                const SizedBox(width: AppSpacing.xs),                Text(                  asset,                  style: TextStyle(                    fontSize: 13.5,                    fontWeight: FontWeight.w600,                    color: CardeoInk.primary(context),                  ),                ),              ],            ),            const SizedBox(height: AppSpacing.xxs),            _TopUpQuoteDetail(              label: context.tr('Exchange rate'),              value:                  '1 $baseCurrency = ${_quoteNumber(quote.rate, decimals: 6)} $quoteCurrency',            ),            _TopUpQuoteDetail(              label: context.tr('Estimated amount'),              value: '${_quoteNumber(quote.rfqAmount)} $receivedCurrency',            ),            _TopUpQuoteDetail(              label: context.tr('Exchange fee'),              value: '${_quoteNumber(quote.fee)} $feeCurrency',            ),          ],        ),      );    }     return Container(      width: double.infinity,      padding: const EdgeInsets.all(12),      decoration: BoxDecoration(        color: Theme.of(context).colorScheme.surfaceContainerHighest,        borderRadius: BorderRadius.circular(12),      ),      child: Column(        crossAxisAlignment: CrossAxisAlignment.start,        children: [          Row(            children: [              CurrencyLogo(symbol: asset, size: 24),              const SizedBox(width: 8),              Text(asset, style: Theme.of(context).textTheme.titleSmall),            ],          ),          const SizedBox(height: 6),          _TopUpQuoteDetail(            label: context.tr('Exchange rate'),            value:                '1 $baseCurrency = ${_quoteNumber(quote.rate, decimals: 6)} $quoteCurrency',          ),          _TopUpQuoteDetail(            label: context.tr('Estimated amount'),            value: '${_quoteNumber(quote.rfqAmount)} $receivedCurrency',          ),          _TopUpQuoteDetail(            label: context.tr('Exchange fee'),            value: '${_quoteNumber(quote.fee)} $feeCurrency',          ),        ],      ),    );  }} /// A small all-caps-weight label above a run of facts inside a panel.class _PanelLabel extends StatelessWidget {  const _PanelLabel(this.text);   final String text;   @override  Widget build(BuildContext context) => Text(        text,        style: TextStyle(          fontSize: 12.5,          fontWeight: FontWeight.w600,          color: CardeoInk.secondary(context),        ),      );} class _TopUpQuoteDetail extends StatelessWidget {  const _TopUpQuoteDetail({    required this.label,    required this.value,    this.emphasis = false,  });   final String label;  final String value;   /// The conclusion of the arithmetic above it: primary ink, heavier.  final bool emphasis;   @override  Widget build(BuildContext context) {    if (context.isCardeoTheme) {      return Padding(        padding: const EdgeInsets.only(top: AppSpacing.xxs),        child: Row(          crossAxisAlignment: CrossAxisAlignment.start,          children: [            Expanded(              child: Text(                label,                style: TextStyle(                  fontSize: 12.5,                  height: 1.35,                  color: CardeoInk.secondary(context),                ),              ),            ),            const SizedBox(width: AppSpacing.sm),            Flexible(              child: Text(                value,                textAlign: TextAlign.end,                style: TextStyle(                  fontSize: emphasis ? 13.5 : 12.5,                  height: 1.35,                  fontWeight: emphasis ? FontWeight.w700 : FontWeight.w600,                  color: CardeoInk.primary(context),                  fontFeatures: const [FontFeature.tabularFigures()],                ),              ),            ),          ],        ),      );    }     return Padding(      padding: const EdgeInsets.only(top: 4),      child: Row(        children: [          Expanded(            child: Text(              label,              style: Theme.of(context).textTheme.bodySmall?.copyWith(                    color: Theme.of(context).colorScheme.onSurfaceVariant,                  ),            ),          ),          const SizedBox(width: 12),          Flexible(            child: Text(              value,              textAlign: TextAlign.end,              style: Theme.of(context).textTheme.bodyMedium?.copyWith(                    fontWeight: FontWeight.w600,                  ),            ),          ),        ],      ),    );  }} String _quoteNumber(double value, {int decimals = 2}) {  final fixed = value.toStringAsFixed(decimals);  return fixed.replaceFirst(RegExp(r'\.?0+$'), '');} /// Desktop "Card details" panel from the D4 artboard: cardholder-facing facts/// that the API already exposes for a card./// The card's own facts, as a titled run of rows.////// This was a two-column grid of 13.5 px facts inside a `CardeoGlassPanel`,/// and it rendered only above 1180 logical pixels โ€” so the screen you open to/// read a card's number never showed one on a phone. The grid also gave every/// fact exactly half the panel and clipped the overflow, which truncated/// `**** **** **** 4271` at 375 before the text scale was raised at all.////// Rows fix both. The label is the row title, the value sits on the right/// with a real measure behind [_DetailValue], the masked number is set in/// Geist Mono like every other account identifier in the app, and the limits/// are read as money in the card's currency instead of a raw `500.0`.class _CardeoCardDetailsPanel extends StatelessWidget {  const _CardeoCardDetailsPanel({    required this.card,    required this.status,    required this.controls,    this.limits,  });   final PaymentCard card;  final CardStatus status;  final CardControlCapabilities? controls;  final CardLimitsInfo? limits;   @override  Widget build(BuildContext context) {    final statusLabel = switch (status) {      CardStatus.active => 'Active',      CardStatus.frozen => 'Frozen',      CardStatus.pending => 'Pending',      CardStatus.cancelled => 'Closed',    };    final network = card.network.trim();    // One fact per row. `Physical ยท Mastercard` in a single value ran past    // the value's measure at a 1.3 text scale and ellipsised the network    // away, which is the half a cardholder is actually looking for.    final type = card.virtual ? 'Virtual' : 'Physical';    final currency = _cardCurrency(card);    final info = this.limits;    final limits = info != null && (info.hasCurrent || info.hasCaps)        ? <String, Object?>{            for (final period in info.periods)              if (period.current != null || period.hasCap)                period.label: period.current == null                    ? 'Up to ${cardLimitLabel(info.currency, period.cap!)}'                    : period.hasCap                        ? '${cardLimitLabel(info.currency, period.current!)} of ${cardLimitLabel(info.currency, period.cap!)}'                        : cardLimitLabel(info.currency, period.current!),          }        : (controls?.limits ?? const <String, dynamic>{});    return Column(      crossAxisAlignment: CrossAxisAlignment.stretch,      children: [        CardeoSectionTitle(title: context.tr('Card details')),        const SizedBox(height: AppSpacing.xs),        CardeoListGroup(          dividerInset: AppSpacing.md,          children: [            CardeoRow(              title: context.tr('Status'),              trailing: _DetailValue(                context.tr(statusLabel),                color: cardeoStatusInk(context, status),              ),              semanticsLabel: context.tr('Status, {p0}', {'p0': statusLabel}),            ),            CardeoRow(              title: context.tr('Number'),              trailing: card.last4.isEmpty                  ? const _DetailValue('Not issued yet')                  : ExcludeSemantics(                      child: CardeoMono(                        'โ€ขโ€ขโ€ขโ€ข ${card.last4}',                        size: 14,                        weight: FontWeight.w500,                      ),                    ),              // Screen readers get the four digits spaced so they are read              // out one at a time rather than as a single number.              semanticsLabel: card.last4.isEmpty                  ? context.tr('Card number, not issued yet')                  : context.tr('Card number, ending {p0}',                      {'p0': card.last4.split('').join(' ')}),            ),            CardeoRow(              title: context.tr('Type'),              trailing: _DetailValue(type),              semanticsLabel: context.tr('Type, {p0}', {'p0': type}),            ),            if (card.discountCode.isNotEmpty)              CardeoRow(                title: context.tr('Discount code'),                subtitle: context.tr('Applied when ordering this card'),                subtitleMaxLines: null,                trailing: Flexible(                    child: Text(card.discountCode,                        textAlign: TextAlign.end,                        style: Theme.of(context).textTheme.titleSmall)),                semanticsLabel:                    '${context.tr('Discount code')}, ${card.discountCode}',              ),            if (network.isNotEmpty)              CardeoRow(                title: context.tr('Network'),                trailing: _DetailValue(cardNetworkName(network)),                semanticsLabel: context                    .tr('Network, {p0}', {'p0': cardNetworkName(network)}),              ),            CardeoRow(              title: context.tr('Provider'),              trailing: _DetailValue(card.providerLabel),              semanticsLabel:                  context.tr('Provider, {p0}', {'p0': card.providerLabel}),            ),            CardeoRow(              title: context.tr('Currency'),              trailing: _DetailValue(currency.toUpperCase()),              semanticsLabel:                  context.tr('Currency, {p0}', {'p0': currency.toUpperCase()}),            ),          ],        ),        if (limits.isNotEmpty) ...[          const SizedBox(height: AppSpacing.lg),          CardeoSectionTitle(title: context.tr('Spending limits')),          const SizedBox(height: AppSpacing.xs),          CardeoListGroup(            dividerInset: AppSpacing.md,            children: [              for (final entry in limits.entries)                CardeoRow(                  title: _limitLabel(entry.key),                  trailing: _DetailValue(_limitValue(currency, entry.value)),                  semanticsLabel: '${_limitLabel(entry.key)} limit, '                      '${_limitValue(currency, entry.value)}',                ),            ],          ),        ],      ],    );  }   String _limitLabel(String key) {    final spaced = key        .replaceAll(RegExp(r'[_-]+'), ' ')        .replaceAllMapped(          RegExp(r'([a-z0-9])([A-Z])'),          (match) => '${match.group(1)} ${match.group(2)}',        )        .trim();    return spaced.isEmpty        ? 'Limit'        : '${spaced[0].toUpperCase()}${spaced.substring(1)}';  }   /// `500.0` is a debug print, not a limit. Anything numeric is read back as  /// money in the card's own currency; anything else is passed through, and a  /// missing limit says so in words rather than in a dash the bundled font  /// subset may not carry.  String _limitValue(String currency, Object? raw) {    if (raw == null) return 'Not set';    final number =        raw is num ? raw.toDouble() : double.tryParse(raw.toString().trim());    if (number == null) {      final text = raw.toString().trim();      return text.isEmpty ? 'Not set' : text;    }    return Money(      currency: currency,      minorUnits: (number * 100).round(),    ).formatted;  }} /// A fact's value on the right of a [CardeoRow].////// [CardeoRowValue] is the money column: one line, no wrap, no ellipsis, and/// it takes its intrinsic width out of the row *before* the title column gets/// any โ€” which is right for `-$174.25` and wrong for a provider name, because/// a long one pushes the label to zero width and then overflows the row. A/// fact is not money, so it is capped at a readable measure and ellipsises/// inside it.class _DetailValue extends StatelessWidget {  const _DetailValue(this.value, {this.color});   final String value;  final Color? color;   @override  Widget build(BuildContext context) {    final theme = Theme.of(context);    final tint = color;    return ConstrainedBox(      constraints: const BoxConstraints(maxWidth: 180),      child: Text(        value,        maxLines: 1,        overflow: TextOverflow.ellipsis,        textAlign: TextAlign.end,        style: (theme.textTheme.titleSmall ??                const TextStyle(fontSize: 14, fontWeight: FontWeight.w700))            .copyWith(          fontWeight: FontWeight.w600,          color: tint == null              ? CardeoInk.primary(context)              : CardeoInk.accent(context, tint),          height: 1.3,          fontFeatures: const [FontFeature.tabularFigures()],        ),      ),    );  }} class _CardHero extends StatelessWidget {  const _CardHero({required this.card, required this.status});   final PaymentCard card;  final CardStatus status;   @override  Widget build(BuildContext context) {    if (context.isCardeoTheme) {      // The stage hands down a tight box at the ISO ratio; the face takes its      // height from there so the living card never has to guess.      return LayoutBuilder(        builder: (context, constraints) => CardFace(          showBalance: true,          enableHoverTilt: false,          card: card,          status: status,          frozen: status == CardStatus.frozen,          height: constraints.maxHeight.isFinite              ? constraints.maxHeight              : CardFace.heightFor(constraints.maxWidth),        ),      );    }    return NeoBankCard(      label: fallbackText(card.label, 'Card'),      last4: card.last4,      network: card.network,      virtual: card.virtual,      status: status,      balanceText: card.balance.formatted,      skin: NeoCardSkin.fromSeed(card.id.isEmpty ? card.label : card.id),      artworkUrl: card.artworkUrl,      artworkAlt: card.cardImageAlt,      textColorHex: card.cardTextColor,    );  }} /// Hero page view for the Cards tab: the selected page shows the live/// [_SecureCardStage] (so reveal and freeze keep working), the neighbours/// show their plain faces, and dots underneath mark the position.class _HeroCarousel extends StatelessWidget {  const _HeroCarousel({    required this.cards,    required this.selectedId,    required this.controller,    required this.stage,    required this.revealed,    required this.onHideSecure,    required this.onSelected,  });   final List<PaymentCard> cards;  final String selectedId;  final PageController controller;  final Widget stage;   /// Keep touches inside the issuer frame while its copy controls are visible.  final bool revealed;  final VoidCallback onHideSecure;  final ValueChanged<PaymentCard> onSelected;   void _changePage(int page) {    onHideSecure();    controller.animateToPage(      page,      duration: const Duration(milliseconds: 300),      curve: Curves.easeOut,    );  }   @override  Widget build(BuildContext context) {    final index =        math.max(0, cards.indexWhere((item) => item.id == selectedId));    return Column(      children: [        AspectRatio(          aspectRatio: 1.586,          child: PageView.builder(            controller: controller,            physics: revealed ? const NeverScrollableScrollPhysics() : null,            clipBehavior: Clip.none,            itemCount: cards.length,            onPageChanged: (page) => onSelected(cards[page]),            itemBuilder: (context, page) {              final item = cards[page];              Widget face = _CardHero(card: item, status: item.status);              if (item.id == selectedId) {                // Never cover the secure iframe with a pointer interceptor:                // copy taps must reach the issuer and keep the session open.                face = stage;              }              return Padding(                padding: const EdgeInsets.symmetric(horizontal: 6),                child: face,              );            },          ),        ),        if (revealed)          Row(            mainAxisAlignment: MainAxisAlignment.spaceBetween,            children: [              IconButton(                tooltip: context.tr('Previous card'),                onPressed: index > 0 ? () => _changePage(index - 1) : null,                icon: const Icon(Icons.chevron_left),              ),              IconButton(                tooltip: context.tr('Next card'),                onPressed: index < cards.length - 1                    ? () => _changePage(index + 1)                    : null,                icon: const Icon(Icons.chevron_right),              ),            ],          ),        const SizedBox(height: 12),        Semantics(          label: context              .tr('Card {p0} of {p1}', {'p0': index + 1, 'p1': cards.length}),          excludeSemantics: true,          child: Column(            children: [              Row(                mainAxisAlignment: MainAxisAlignment.center,                children: [                  for (var dot = 0; dot < cards.length; dot++)                    AnimatedContainer(                      key: ValueKey('card-page-indicator-${cards[dot].id}'),                      duration: const Duration(milliseconds: 220),                      curve: Curves.easeOut,                      margin: EdgeInsets.symmetric(                        horizontal: cards.length > 10 ? 2 : 4,                      ),                      width: dot == index ? 22 : 6,                      height: 6,                      decoration: BoxDecoration(                        borderRadius: BorderRadius.circular(3),                        color: dot == index                            ? CardeoPalette.of(context).accent                            : CardeoInk.secondary(context),                      ),                    ),                ],              ),              if (cards.length > 5) ...[                const SizedBox(height: 8),                Text(                  context.tr(                      '{p0} of {p1}', {'p0': index + 1, 'p1': cards.length}),                  style: TextStyle(                    fontSize: 12,                    color: CardeoInk.secondary(context),                    fontFeatures: const [FontFeature.tabularFigures()],                  ),                ),              ],            ],          ),        ),      ],    );  }} class _SecureCardStage extends StatelessWidget {  const _SecureCardStage({    required this.card,    required this.status,    required this.isCardeo,    required this.secureUrl,    required this.loading,    required this.onReady,    required this.onError,    required this.onRefresh,  });   final PaymentCard card;  final CardStatus status;  final bool isCardeo;  final String? secureUrl;  final bool loading;  final VoidCallback onReady;  final ValueChanged<String> onError;  final VoidCallback onRefresh;   @override  Widget build(BuildContext context) {    final revealed = secureUrl != null && !loading;    final artworkPalette =        CardeoPalette.fromDesign(Brightness.dark, context.brandDesign);    // 700 ms of rotation is exactly the kind of motion a viewer who has asked    // for reduced motion is asking not to see, and a flip cannot be made    // "gentler" โ€” it either turns or it does not. Under reduced motion the    // tween collapses to zero and the secure face is simply there.    const flip = Duration(milliseconds: 700);    // ISO card proportions so the issuer's secure face fits without cropping.    return AspectRatio(      aspectRatio: isCardeo ? CardFace.aspectRatio : 8 / 5,      child: TweenAnimationBuilder<double>(        duration: isCardeo ? CardeoMotion.of(context, flip) : flip,        curve: Curves.easeInOutCubic,        tween: Tween(end: revealed ? 1 : 0),        builder: (context, progress, _) {          // The issuer frame is a platform view: a zero-opacity subtree is          // not composited on the web, so the page would only start loading          // once the flip reveals it. Keep it painted (unrotated) beneath an          // opaque backdrop while it loads, and rotate it in only for the          // second half of the flip.          final frontShown = progress <= .5;          return Stack(            fit: StackFit.expand,            // The living card seats itself with a shadow that falls below its            // own edge, and a Stack clips hard by default โ€” which sliced the            // daylight ambient off flush with the artwork and put the hero            // back to floating on paper. The stage is inset by the screen's            // own padding, so the fall has somewhere to land.            clipBehavior: Clip.none,            children: [              if (secureUrl != null)                _CardFlipFace(                  angle: frontShown ? 0 : math.pi * (1 - progress),                  visible: true,                  child: _SecureCardInline(                    key: ValueKey(secureUrl),                    url: secureUrl!,                    onReady: onReady,                    onError: onError,                    onRefresh: onRefresh,                  ),                ),              if (frontShown) ...[                if (secureUrl != null)                  DecoratedBox(                    decoration: BoxDecoration(                      color: artworkPalette.navigation,                      borderRadius: const BorderRadius.all(Radius.circular(18)),                    ),                  ),                _CardFlipFace(                  angle: math.pi * progress,                  visible: true,                  child: CardeoTiltCard(                    enabled: isCardeo && progress == 0 && !loading,                    child: _CardHero(card: card, status: status),                  ),                ),              ],              if (loading)                ClipRRect(                  borderRadius:                      const BorderRadius.all(Radius.circular(AppRadii.md)),                  child: isCardeo                      // The artwork stays night in both themes, so this scrim                      // and its label are pinned to the Twilight palette                      // rather than the page's. `LoadingState` follows the                      // page, which put night ink on a night scrim in                      // daylight and made the label invisible.                      ? ColoredBox(                          color: artworkPalette.paper.withValues(alpha: .72),                          child: Semantics(                            liveRegion: true,                            label: context.tr('Loading secure card details'),                            child: ExcludeSemantics(                              child: Center(                                child: Column(                                  mainAxisSize: MainAxisSize.min,                                  children: [                                    SizedBox.square(                                      dimension: 22,                                      child: context.brandDesign.isConfigured                                          ? AppProgressIndicator(                                              strokeWidth: 2,                                              color: artworkPalette.accent,                                            )                                          : CircularProgressIndicator(                                              strokeWidth: 2,                                              color: artworkPalette.accent,                                            ),                                    ),                                    const SizedBox(height: AppSpacing.sm),                                    Text(                                      context.tr('Loading secure card details'),                                      textAlign: TextAlign.center,                                      style: TextStyle(                                        fontSize: 12,                                        color: artworkPalette.textSecondary,                                      ),                                    ),                                  ],                                ),                              ),                            ),                          ),                        )                      : ColoredBox(                          color: context.brandDesign                              .color(                                  Theme.of(context).brightness, 'cardOverlay',                                  fallback: Colors.black)                              .withValues(alpha: Colors.black54.a),                          child: LoadingState(                            label: context.tr('Loading secure card details'),                          ),                        ),                ),            ],          );        },      ),    );  }} class _CardFlipFace extends StatelessWidget {  const _CardFlipFace({    required this.angle,    required this.visible,    required this.child,  });   final double angle;  final bool visible;  final Widget child;   @override  Widget build(BuildContext context) {    final transform = Matrix4.identity()      ..setEntry(3, 2, .001)      ..rotateY(angle);    return IgnorePointer(      ignoring: !visible,      child: Opacity(        opacity: visible ? 1 : 0,        child: Transform(          alignment: Alignment.center,          transform: transform,          child: child,        ),      ),    );  }} class _ActionGrid extends StatelessWidget {  const _ActionGrid({    required this.isBusy,    required this.frozen,    required this.secureVisible,    required this.onTopUp,    required this.onToggleFreeze,    required this.onTransactions,    required this.onSecureDetails,    required this.isCardeo,    required this.canUpdateLimits,    required this.onLimits,  });   final bool isBusy;  final bool frozen;  final bool secureVisible;  final VoidCallback onTopUp;  final VoidCallback onToggleFreeze;  final VoidCallback onTransactions;  final VoidCallback onSecureDetails;  final bool isCardeo;  final bool canUpdateLimits;  final VoidCallback? onLimits;   @override  Widget build(BuildContext context) {    if (isCardeo) {      return Row(        children: [          _CardeoControlAction(            icon: secureVisible                ? Icons.visibility_off_outlined                : Icons.visibility_outlined,            label: secureVisible ? context.tr('Hide') : context.tr('View'),            onTap: isBusy ? null : onSecureDetails,          ),          _CardeoControlAction(            icon: frozen ? Icons.lock_open_rounded : Icons.ac_unit_rounded,            label: frozen ? context.tr('Unfreeze') : context.tr('Freeze'),            onTap: isBusy ? null : onToggleFreeze,          ),          // A control that can never fire is not a control: when the card          // product does not support limits the action is dropped rather than          // left dimmed forever.          if (canUpdateLimits)            _CardeoControlAction(              icon: Icons.tune_rounded,              label: context.tr('Limits'),              onTap: isBusy ? null : onLimits,            ),          // Was labelled "More" behind a horizontal-dots glyph, and went          // straight to the transaction list. It is one destination, so it is          // named after it.          _CardeoControlAction(            icon: Icons.receipt_long_outlined,            label: context.tr('Activity'),            onTap: isBusy ? null : onTransactions,          ),        ],      );    }    return Row(      children: [        NeoQuickAction(          icon: Icons.account_balance_wallet_outlined,          label: context.tr('Balance'),          onTap: isBusy || frozen ? null : onTopUp,        ),        const SizedBox(width: AppSpacing.xs),        NeoQuickAction(          icon: frozen ? Icons.lock_open_outlined : Icons.lock_outline,          label: frozen ? context.tr('Unfreeze') : context.tr('Freeze'),          onTap: isBusy ? null : onToggleFreeze,        ),        const SizedBox(width: AppSpacing.xs),        NeoQuickAction(          icon: Icons.receipt_long_outlined,          label: context.tr('Activity'),          onTap: isBusy ? null : onTransactions,        ),        const SizedBox(width: AppSpacing.xs),        NeoQuickAction(          icon: secureVisible              ? Icons.visibility_off_outlined              : Icons.visibility_outlined,          label: secureVisible ? context.tr('Hide') : context.tr('Details'),          onTap: isBusy ? null : onSecureDetails,        ),      ],    );  }} /// One circle action under the card face.////// The freeze control swaps between two states, so its glyph and label ride a/// [CardeoStateSwitch]: 200 ms fade with a 2 pt rise, instant under reduced/// motion. Nothing here moves on arrival โ€” the card's specular pass owns that/// beat โ€” and the actions themselves are pressed hundreds of times a month, so/// they get the 120 ms press and nothing more.class _CardeoControlAction extends StatelessWidget {  const _CardeoControlAction({    required this.icon,    required this.label,    required this.onTap,  });   final IconData icon;  final String label;  final VoidCallback? onTap;   @override  Widget build(BuildContext context) {    final enabled = onTap != null;    final theme = Theme.of(context);    return Expanded(      child: CardeoPressable(        onTap: onTap,        enabled: enabled,        borderRadius: BorderRadius.circular(AppRadii.pill),        semanticsLabel: label,        child: Opacity(          opacity: enabled ? 1 : CardeoOpacity.disabled,          child: CardeoStateSwitch(            child: KeyedSubtree(              key: ValueKey<String>(label),              child: Column(                mainAxisSize: MainAxisSize.min,                children: [                  Container(                    width: 44,                    height: 44,                    decoration: BoxDecoration(                      shape: BoxShape.circle,                      color: CardeoSurface.of(context, 2),                      border: CardeoBorders.subtleOf(context),                    ),                    child: Icon(                      icon,                      color: CardeoInk.primary(context),                      size: 19,                    ),                  ),                  const SizedBox(height: AppSpacing.xxs),                  Text(                    label,                    maxLines: 1,                    overflow: TextOverflow.ellipsis,                    style: theme.textTheme.labelSmall?.copyWith(                      color: CardeoInk.secondary(context),                    ),                  ),                ],              ),            ),          ),        ),      ),    );  }} /// A card transaction as a [CardeoRow]: the list rhythm every Cardeo screen/// shares, with the amount tabular on the right.////// Two things were wrong rather than plain. The direction came from/// `CardTransactionActivity.isCredit`, which is inverted, so a groceries/// purchase arrived green with a `+` and a downward arrow; it now runs through/// [cardActivityIsMoneyIn], shared with the full ledger so the two screens/// cannot disagree about which way money moved. And every settled purchase/// carried a "Completed" caption, which is the normal case on a card and/// therefore says nothing โ€” only the states that ask something of the holder/// keep the caption slot now.class _CardeoActivityRow extends StatelessWidget {  const _CardeoActivityRow({    required this.item,    required this.timeLabel,    required this.identityLabel,    required this.onTap,    this.ledger,  });   final CardTransactionActivity item;   /// The ledger view of this row after fee grouping, when the card feed  /// carried one; its fees ride on the amount and in the descriptor.  final LedgerTransaction? ledger;  final String timeLabel;  final String? identityLabel;  final VoidCallback onTap;   @override  Widget build(BuildContext context) {    final palette = CardeoPalette.of(context);    // The card's own currency, so this column reconciles with the balance    // directly above it; the original sits in the descriptor when it differs.    final fees = ledger?.cardFees ?? const <LedgerTransaction>[];    final settlement =        fees.isNotEmpty ? cardListAmount(ledger!) : item.settlementAmount;    final moneyIn = cardActivityIsMoneyIn(item);    final title = item.title.trim().isEmpty        ? (item.subtitle.trim().isEmpty            ? context.tr('Card activity')            : item.subtitle)        : item.title;    // Keep the original FX amount alongside the date and card identity.    // Each fact can wrap at narrow widths without squeezing the amount.    final original = item.transactionAmount;    final foreign =        original != null && original.currency != settlement.currency;    final descriptor = foreign        ? original.formatted.replaceFirst('-', '')        : (item.title.trim().isEmpty ? '' : item.subtitle.trim());    final details = [      timeLabel,      if (identityLabel != null) identityLabel!,      if (descriptor.isNotEmpty) descriptor,      if (ledger != null)        if (cardFeeCaption(ledger!) case final caption?) caption,    ];    final attention = _cardeoAttentionStatus(item.status);    final tone = _cardActivityStatusTone(item.status);    final magnitude = settlement.formatted.replaceFirst('-', '');    final value = settlement.minorUnits == 0        ? magnitude        : '${moneyIn ? '+' : '-'}$magnitude';    return CardeoRow(      leading: CardeoIconTile(        icon: moneyIn ? Icons.south_rounded : _activityIconFor(item.category),        color: moneyIn ? palette.success : palette.accent,      ),      title: title,      subtitle: details.join('\n'),      subtitleMaxLines: null,      trailing: CardeoRowValue(        value: value,        caption: attention,        color: moneyIn ? palette.success : null,        captionColor: attention == null            ? null            : (tone == FinanceStatusTone.danger                ? palette.danger                : tone == FinanceStatusTone.warning                    ? palette.warning                    : null),      ),      semanticsLabel: [        title,        ...details,        '${moneyIn ? 'received' : 'spent'} $magnitude',        if (attention != null) attention,      ].join(', '),      onTap: onTap,    );  }} /// Which statuses earn the caption slot on a Cardeo row.////// A settled purchase is what a card does all day, so it is left unmarked and/// the row reads as a clean line of money. Anything that is still moving, or/// that failed, is called out. Returns null when there is nothing to say.String? _cardeoAttentionStatus(String value) {  final normalised = value.toLowerCase().replaceAll('_', ' ').trim();  if (normalised.isEmpty) return null;  const quiet = <String>{    'complete',    'completed',    'closed',    'settled',    'booked',    'approved',    'posted',  };  if (quiet.contains(normalised)) return null;  // Hoppa's card ledger says "fail" for a declined purchase.  if (normalised == 'fail') return 'Failed';  final raw = value.trim();  return '${raw[0].toUpperCase()}${raw.substring(1)}';} /// The card's own balance, and how far its month has run against its limit.////// Replaces the hand-mixed gradient box that carried a 20 px balance: the/// number is now a [CardeoAmount] at `large`, and the panel is a real surface/// step with the daylight ambient under it.////// It is deliberately not a button any more. The panel used to be the only/// way to add money โ€” a whole-panel tap hinted at by a small `+` tile in the/// corner, which is an affordance you have to already know about. The screen/// now carries an explicit `CardeoGlassButton` directly beneath it, so this/// block goes back to doing one job: stating the balance and how far the/// month has run against the limit.class _CardBalanceBlock extends StatelessWidget {  const _CardBalanceBlock({required this.card});   final PaymentCard card;   @override  Widget build(BuildContext context) {    final balance = card.balance;    return Container(      padding: const EdgeInsets.fromLTRB(        AppSpacing.md,        14,        AppSpacing.md,        AppSpacing.md,      ),      decoration: BoxDecoration(        color: CardeoSurface.of(context, 1),        borderRadius: BorderRadius.circular(AppRadii.lg),        border: CardeoBorders.subtleOf(context),        boxShadow: CardeoShadows.ambientOf(context),      ),      child: Column(        crossAxisAlignment: CrossAxisAlignment.stretch,        children: [          Row(            children: [              Expanded(                child: Column(                  crossAxisAlignment: CrossAxisAlignment.start,                  children: [                    Text(                      context.tr('CARD BALANCE'),                      style: CardeoTextStyles.label(context),                    ),                    const SizedBox(height: AppSpacing.xxs),                    CardeoAmount(                      amount: balance.minorUnits / 100,                      currency: balance.currency,                    ),                  ],                ),              ),            ],          ),          const SizedBox(height: AppSpacing.md),          SizedBox(            height: 1,            child: ColoredBox(color: CardeoBorders.subtleSideOf(context).color),          ),          const SizedBox(height: AppSpacing.sm),          CardSpendMeter(            spent: card.spendThisMonth.minorUnits.abs() / 100,            limit: card.limit.minorUnits / 100,            currency: card.spendThisMonth.currency,          ),        ],      ),    );  }} /// The page's loading state: the shape of the card screen, held still.////// A spinner tells you to wait; a skeleton tells you what is coming, and this/// one is measured off the real page โ€” the artwork at the ISO ratio, the four/// circle actions, the balance panel with its rule and meter, the CTA at its/// resting height, then a titled group of rows. Every block is/// `sheen: false` and the whole column is ONE `CardeoSheen.text` host, so the/// screen costs one sweep rather than a dozen `saveLayer`s.class _CardDetailSkeleton extends StatelessWidget {  const _CardDetailSkeleton();   @override  Widget build(BuildContext context) {    return Semantics(      label: context.tr('Loading card'),      liveRegion: true,      excludeSemantics: true,      child: ListView(        padding: const EdgeInsets.fromLTRB(20, 6, 20, 28),        physics: const AlwaysScrollableScrollPhysics(),        children: [          CardeoSheen.text(            intensity: CardeoSheenIntensity.soft,            child: Column(              crossAxisAlignment: CrossAxisAlignment.stretch,              children: [                LayoutBuilder(                  builder: (context, constraints) => CardeoSkeleton.card(                    height: CardFace.heightFor(constraints.maxWidth),                    sheen: false,                  ),                ),                const SizedBox(height: AppSpacing.md),                Row(                  children: [                    for (var i = 0; i < 4; i++)                      const Expanded(                        child: Column(                          children: [                            CardeoSkeleton.avatar(size: 44, sheen: false),                            SizedBox(height: AppSpacing.xxs),                            CardeoSkeleton.line(                              width: 30,                              height: 9,                              sheen: false,                            ),                          ],                        ),                      ),                  ],                ),                const SizedBox(height: AppSpacing.md),                Container(                  padding: const EdgeInsets.fromLTRB(                    AppSpacing.md,                    14,                    AppSpacing.md,                    AppSpacing.md,                  ),                  decoration: BoxDecoration(                    color: CardeoSurface.of(context, 1),                    borderRadius: BorderRadius.circular(AppRadii.lg),                    border: CardeoBorders.subtleOf(context),                  ),                  child: const Column(                    crossAxisAlignment: CrossAxisAlignment.start,                    children: [                      CardeoSkeleton.line(width: 96, height: 9, sheen: false),                      SizedBox(height: AppSpacing.xxs),                      CardeoSkeleton.amount(sheen: false),                      SizedBox(height: AppSpacing.md),                      CardeoSkeleton.line(                        widthFactor: .55,                        height: 10,                        sheen: false,                      ),                      SizedBox(height: AppSpacing.xs),                      CardeoSkeleton(height: 6, sheen: false),                    ],                  ),                ),                const SizedBox(height: AppSpacing.md),                const CardeoSkeleton(                  height: 54,                  radius: AppRadii.pill,                  sheen: false,                ),                const SizedBox(height: AppSpacing.lg),                const CardeoSkeleton.line(width: 132, height: 11, sheen: false),                const SizedBox(height: AppSpacing.sm),                const _SkeletonGroup(rows: 3),              ],            ),          ),        ],      ),    );  }} /// The activity section while it loads: four rows the shape of the four that/// will arrive, under one sheen host.class _ActivitySkeleton extends StatelessWidget {  const _ActivitySkeleton();   @override  Widget build(BuildContext context) {    return Semantics(      label: context.tr('Loading card activity'),      liveRegion: true,      excludeSemantics: true,      child: const CardeoSheen.text(        intensity: CardeoSheenIntensity.soft,        child: _SkeletonGroup(rows: 4),      ),    );  }} /// A `CardeoListGroup`'s silhouette with nothing in it yet.class _SkeletonGroup extends StatelessWidget {  const _SkeletonGroup({required this.rows});   final int rows;   @override  Widget build(BuildContext context) {    return Container(      decoration: BoxDecoration(        color: CardeoSurface.of(context, 1),        borderRadius: BorderRadius.circular(AppRadii.lg),        border: CardeoBorders.subtleOf(context),      ),      child: Column(        children: [          for (var i = 0; i < rows; i++) const CardeoSkeleton.row(),        ],      ),    );  }} /// One sheen clock for the screen, and only for Cardeo.class _SheenLayer extends StatelessWidget {  const _SheenLayer({required this.enabled, required this.child});   final bool enabled;  final Widget child;   @override  Widget build(BuildContext context) {    if (!enabled || CardeoSheenScope.existsAbove(context)) return child;    return CardeoSheenScope(child: child);  }} /// White-label card activity row. The Cardeo rendering is [_CardeoActivityRow].class _ActivityTile extends StatelessWidget {  const _ActivityTile({    required this.item,    required this.timeLabel,    this.ledger,    required this.identityLabel,    required this.onTap,  });   final CardTransactionActivity item;  final LedgerTransaction? ledger;  final String timeLabel;  final String? identityLabel;  final VoidCallback onTap;   @override  Widget build(BuildContext context) {    final amount = ledger != null && ledger!.cardFees.isNotEmpty        ? cardListAmount(ledger!)        : item.displayAmount;    return Column(      crossAxisAlignment: CrossAxisAlignment.stretch,      children: [        FinanceTransactionRow(          onTap: onTap,          logoUrl: item.merchantLogoUrl,          currency: amount.currency,          fallbackIcon: _activityIconFor(item.category),          title: item.title,          subtitle: item.subtitle,          amount: amount.formatted,          amountColor: item.isCredit              ? context.brandDesign.color(                  Theme.of(context).brightness, 'success',                  fallback: Colors.green.shade700)              : null,          secondaryAmount: item.secondarySettlementAmount?.formatted,          status: item.status,          statusTone: _cardActivityStatusTone(item.status),        ),        InkWell(          onTap: onTap,          child: Padding(            padding: const EdgeInsets.only(left: 72, right: 16, bottom: 12),            child: Text(              [                timeLabel,                if (identityLabel != null) identityLabel!,                if (ledger != null)                  if (cardFeeCaption(ledger!) case final caption?) caption,              ].join('\n'),              style: Theme.of(context).textTheme.bodySmall,            ),          ),        ),      ],    );  }} FinanceStatusTone _cardActivityStatusTone(String value) {  final status = value.toLowerCase().replaceAll('_', ' ').trim();  if (const {'complete', 'completed', 'closed', 'settled'}.contains(status)) {    return FinanceStatusTone.success;  }  if (const {'failed', 'fail', 'declined', 'rejected', 'cancelled', 'canceled'}      .contains(status)) {    return FinanceStatusTone.danger;  }  if (const {'pending', 'processing', 'in progress'}.contains(status)) {    return FinanceStatusTone.warning;  }  return FinanceStatusTone.neutral;} class _EmptyActivityPanel extends StatelessWidget {  const _EmptyActivityPanel();   @override  Widget build(BuildContext context) {    if (context.isCardeoTheme) {      return CardeoEmptyState(        compact: true,        icon: Icons.receipt_long_outlined,        title: context.tr('No activity yet'),        body: context.tr('Purchases on this card appear here as they post.'),      );    }    return _Panel(      child: Row(        children: [          const Icon(Icons.receipt_long_outlined),          const SizedBox(width: 12),          Expanded(            child: Text(                context.tr('No card activity has posted for this card yet.')),          ),        ],      ),    );  }} class _ControlTile extends StatelessWidget {  const _ControlTile({    required this.enabled,    required this.icon,    required this.title,    required this.subtitle,    required this.onTap,    this.destructive = false,    this.cardeoSubtitle,    this.trailing,  });   final bool enabled;  final IconData icon;  final String title;  final String subtitle;  final VoidCallback? onTap;  final bool destructive;   /// Replaces the chevron: a switch for a row that is a setting rather than  /// a door to one.  final Widget? trailing;   /// Shorter copy for the Cardeo rendering only.  ///  /// A `ListTile` subtitle wraps to as many lines as it needs, so every other  /// brand can carry a sentence; a [CardeoRow] subtitle is deliberately one  /// line, and at 375 with a 1.3 text scale it has about 219 pt to say it in.  /// "View card number, expiry date and security code" does not fit in that,  /// and an ellipsised explanation explains nothing. Rather than shorten the  /// copy for every tenant, the row takes a second, tighter phrasing that  /// only Cardeo reads โ€” white-label pixels are untouched.  final String? cardeoSubtitle;   @override  Widget build(BuildContext context) {    if (context.isCardeoTheme) {      final palette = CardeoPalette.of(context);      // Danger lives on the glyph, never on the row's title: one red mark is      // a warning, a red line of text is an alarm.      return CardeoRow(        leading: CardeoIconTile(          icon: icon,          color: destructive ? palette.danger : palette.accent,        ),        title: title,        subtitle: cardeoSubtitle ?? subtitle,        trailing: trailing ?? (onTap == null ? null : CardeoRow.chevron),        enabled: enabled,        onTap: onTap,      );    }    final color = destructive ? Theme.of(context).colorScheme.error : null;     return Padding(      padding: const EdgeInsets.only(bottom: AppSpacing.xs),      child: NeoSurfaceCard(        padding: EdgeInsets.zero,        child: ListTile(          enabled: enabled,          leading: Icon(icon, color: color),          title: Text(title, style: TextStyle(color: color)),          subtitle: Text(subtitle),          trailing: trailing ??              (onTap == null ? null : const Icon(Icons.chevron_right)),          onTap: enabled ? onTap : null,        ),      ),    );  }} /// The switch a [_ControlTile] carries when the row is itself the setting.////// On Cardeo it is the Material switch with zero padding, for the same reason/// Settings uses that shape: the Cupertino control ignores `SwitchThemeData`/// and its off state fell below contrast on paper, and the 60 pt Material box/// would park the track 4 px inboard of the chevrons above and below it./// Other brands keep the platform control the rest of their screens use.class _ControlSwitch extends StatelessWidget {  const _ControlSwitch({    required this.value,    required this.semanticsLabel,    required this.onChanged,  });   final bool value;   /// The setting's name: the row's text is not part of the switch node, so  /// without it a screen reader announces an unnamed toggle.  final String semanticsLabel;  final ValueChanged<bool>? onChanged;   @override  Widget build(BuildContext context) => Semantics(        label: semanticsLabel,        child: context.isCardeoTheme            ? Switch(                value: value,                padding: EdgeInsets.zero,                onChanged: onChanged,              )            : Switch.adaptive(value: value, onChanged: onChanged),      );} /// The same facts in a row's worth of words.////// The long form is a sentence, which a [CardeoRow] cannot hold on one line;/// this is the list on its own, in the shortest honest words โ€” "tap" for/// contactless, "abroad" for international โ€” so all four still fit at 375/// with a 1.3 text scale.String _advancedControlSummaryShort(CardControlCapabilities controls) {  final labels = <String>[    if (controls.canControlOnlinePayments) 'Online',    if (controls.canControlContactless) 'tap',    if (controls.canControlAtm) 'ATM',    if (controls.canControlInternational) 'abroad',  ];  if (labels.isEmpty) return 'Supported by this card';  return labels.join(', ');} String _advancedControlSummary(CardControlCapabilities controls) {  final labels = <String>[    if (controls.canControlOnlinePayments) 'online',    if (controls.canControlContactless) 'contactless',    if (controls.canControlAtm) 'ATM',    if (controls.canControlInternational) 'international',  ];  return '${labels.join(', ')} controls supported by this card product.';} class _Panel extends StatelessWidget {  const _Panel({required this.child});   final Widget child;   @override  Widget build(BuildContext context) {    return NeoSurfaceCard(child: child);  }} class _SectionTitle extends StatelessWidget {  const _SectionTitle(this.title);   final String title;   @override  Widget build(BuildContext context) {    return Row(      children: [        Expanded(          child: Text(            title,            style: context.isCardeoTheme                ? Theme.of(context).textTheme.titleSmall?.copyWith(                      fontWeight: FontWeight.w500,                    )                : Theme.of(context).textTheme.titleMedium,          ),        ),      ],    );  }} IconData _activityIconFor(CardTransactionCategory category) {  return switch (category) {    CardTransactionCategory.purchase => Icons.shopping_bag_outlined,    CardTransactionCategory.crypto => Icons.currency_bitcoin,    CardTransactionCategory.control => Icons.tune,  };} String _cardCurrency(PaymentCard card) {  if (card.currency.trim().isNotEmpty) {    return card.currency;  }  if (card.limit.currency.trim().isNotEmpty) {    return card.limit.currency;  }  if (card.spendThisMonth.currency.trim().isNotEmpty) {    return card.spendThisMonth.currency;  }   return 'EUR';} String? _secureWidgetUrl(Map<String, dynamic> data) {  for (final key in const [    'widgetUrl',    'WidgetUrl',    'url',    'Url',    'iframeUrl',    'IframeUrl',  ]) {    final value = data[key]?.toString().trim();    if (value != null && value.startsWith(RegExp(r'https?://'))) {      return value;    }  }   for (final key in const ['data', 'Data', 'result', 'Result']) {    final nested = data[key];    if (nested is Map<String, dynamic>) {      final value = _secureWidgetUrl(nested);      if (value != null) {        return value;      }    } else if (nested is Map) {      final normalized =          nested.map((key, value) => MapEntry(key.toString(), value));      final value = _secureWidgetUrl(normalized);      if (value != null) {        return value;      }    }  }   return null;} String _secureWidgetPresentationUrl(  String value,  PaymentCard card, {  required Color backgroundColor,}) {  final uri = Uri.parse(value);  final parameters = <String, String>{    ...uri.queryParameters,    'show-new': 'true',    'backgroundColor': _hexColor(backgroundColor),    'cardForm': card.virtual ? 'Virtual' : 'Physical',    'cardStatus': card.statusLabel,    'cardCurrency': card.currency,  };  if (card.secureArtworkUrl.isNotEmpty) {    parameters['cardBackground'] = card.secureArtworkUrl;  }  if (card.cardTextColor.isNotEmpty) {    parameters['cardTextColor'] = card.cardTextColor;  }  parameters['cardLabel'] =      card.cardTypeName.isNotEmpty ? card.cardTypeName : card.displayLabel;  if (card.network.isNotEmpty) parameters['cardNetwork'] = card.network;  if (kIsWeb) {    // Lets the widget page post its status back to this app only.    parameters['parentOrigin'] = Uri.base.origin;  }  return uri.replace(queryParameters: parameters).toString();} String _hexColor(Color color) {  final value = color.toARGB32() & 0x00ffffff;  return '#${value.toRadixString(16).padLeft(6, '0')}';} // Some issuer pages render a session error without posting a status message.// Observe that specific error heading without exporting any card data.const _secureCardStatusScript = r'''(() => {  if (window.__cardSecureStatusInstalled) return;  window.__cardSecureStatusInstalled = true;  let reported = false;  const report = () => {    if (reported) return;    reported = true;    CardSecureStatus.postMessage(JSON.stringify({      type: 'card-secure-widget-status', status: 'error'    }));  };  window.addEventListener('message', (event) => {    if (event.source !== window || event.origin !== window.location.origin) return;    if (event.data && event.data.type === 'card-secure-widget-status' &&        event.data.status === 'error') report();  });  const check = () => {    const nodes = document.createTreeWalker(document.documentElement, NodeFilter.SHOW_TEXT);    while (nodes.nextNode()) {      if (nodes.currentNode.textContent.trim() === 'Unable to Load Card Data') {        report();        break;      }    }  };  check();  new MutationObserver(check).observe(document.documentElement, {    childList: true, subtree: true, characterData: true  });})();'''; class _SecureCardInline extends StatefulWidget {  const _SecureCardInline({    required this.url,    required this.onReady,    required this.onError,    required this.onRefresh,    super.key,  });   final String url;  final VoidCallback onReady;  final ValueChanged<String> onError;  final VoidCallback onRefresh;   @override  State<_SecureCardInline> createState() => _SecureCardInlineState();} class _SecureCardInlineState extends State<_SecureCardInline> {  WebViewController? _controller;  Timer? _readyTimer;  bool _readyReported = false;  bool _failed = false;   bool _pageLoaded = false;   void _reportError() {    if (!mounted || _failed) return;    _failed = true;    _readyTimer?.cancel();    widget.onError('Secure card data could not be loaded.');  }   @override  void initState() {    super.initState();    if (kIsWeb) return;    _controller = WebViewController()      ..setJavaScriptMode(JavaScriptMode.unrestricted)      ..setBackgroundColor(Colors.transparent)      ..addJavaScriptChannel(        'CardSecureStatus',        onMessageReceived: (message) {          if (!mounted || _failed) return;          try {            final payload = jsonDecode(message.message);            if (payload is Map &&                payload['type'] == 'card-secure-widget-status' &&                payload['status'] == 'error') {              _reportError();            }          } on FormatException {            // Ignore messages outside the widget status protocol.          }        },      )      ..setNavigationDelegate(        NavigationDelegate(          onNavigationRequest: (request) {            if (_pageLoaded &&                request.isMainFrame &&                request.url == widget.url) {              if (mounted && !_failed) {                _failed = true;                _readyTimer?.cancel();                widget.onRefresh();              }              return NavigationDecision.prevent;            }            return NavigationDecision.navigate;          },          onPageFinished: (_) {            _pageLoaded = true;            if (!mounted || _failed) return;            // Forward status only; secure card fields stay inside the WebView.            unawaited(_controller!.runJavaScript(_secureCardStatusScript));            if (_readyTimer != null || _readyReported) return;            _readyTimer = Timer(const Duration(seconds: 2), () {              if (!mounted || _failed) return;              _readyReported = true;              widget.onReady();            });          },          onWebResourceError: (error) {            if (error.isForMainFrame == true) _reportError();          },        ),      )      ..loadRequest(Uri.parse(widget.url));  }   @override  void dispose() {    _readyTimer?.cancel();    super.dispose();  }   @override  Widget build(BuildContext context) {    // The widget page draws the card edge-to-edge on a transparent page;    // clip to the same radius as the front face so the flip looks seamless.    return ClipRRect(      borderRadius: BorderRadius.circular(18),      child: ColoredBox(        color: Theme.of(context).scaffoldBackgroundColor,        child: kIsWeb            // The widget page keeps a small gutter around its card; scale it            // up a touch so the gutter is cropped by the rounded clip.            ? Transform.scale(                scale: 1.045,                child: SecureCardFrame(                  url: widget.url,                  onReady: widget.onReady,                  onError: widget.onError,                  onRefresh: widget.onRefresh,                ),              )            : WebViewWidget(controller: _controller!),      ),    );  }} PlatformResource? _targetBudgetForCard(  PaymentCard card,  List<PlatformResource> budgets,) {  final cardBudgetId = card.budgetId.trim();  if (cardBudgetId.isEmpty) {    return null;  }   for (final budget in budgets) {    if (_budgetId(budget) == cardBudgetId) {      return budget;    }  }   return null;} List<PlatformResource> _equalsBudgetAccounts(List<PlatformResource> budgets) {  return budgets.where(_isEqualsBudgetTransferSource).toList()    ..sort((left, right) {      final leftMain = _isEqualsMainBudget(left);      final rightMain = _isEqualsMainBudget(right);      if (leftMain != rightMain) {        return leftMain ? -1 : 1;      }      return _budgetTitle(left).compareTo(_budgetTitle(right));    });} bool _isEqualsBudgetTransferSource(PlatformResource budget) {  final provider = _textValue(budget.metadata, const [    'provider',    'Provider',    'bankProvider',    'BankProvider',  ])?.toLowerCase().replaceAll(' ', '');  final providerType = _textValue(budget.metadata, const [    'providerType',    'ProviderType',    'bankProviderType',    'BankProviderType',  ]);  final accountType = _textValue(budget.metadata, const [    'accountType',    'AccountType',    'type',    'Type',  ])?.toLowerCase();   return (provider == 'equalsmoney' || providerType == '2') &&      (accountType == 'budget' || accountType == 'accountbalance');} bool _isEqualsMainBudget(PlatformResource budget) {  return _budgetTitle(budget).toLowerCase().trim() == 'account balance';} String _budgetId(PlatformResource budget) {  return _textValue(budget.metadata, const [        'accountId',        'AccountId',        'budgetId',        'BudgetId',        'id',        'Id',      ]) ??      budget.id;} String _budgetTitle(PlatformResource budget) {  return _textValue(budget.metadata, const [        'displayName',        'DisplayName',        'name',        'Name',        'title',        'Title',      ]) ??      budget.title;} List<String> _budgetCurrencies(PlatformResource budget) {  final values = <String>{    ..._listTextValue(budget.metadata, const [      'supportedCurrencies',      'SupportedCurrencies',      'currencies',      'Currencies',    ]),    for (final bankAccount in _listMapValue(budget.metadata, const [      'linkedBankAccounts',      'LinkedBankAccounts',    ]))      if (_textValue(bankAccount, const ['currency', 'Currency']) != null)        _textValue(bankAccount, const ['currency', 'Currency'])!,  };   return values      .map((item) => item.trim().toUpperCase())      .where((item) => item.isNotEmpty)      .toList();} List<String> _sharedBudgetCurrencies(  PlatformResource left,  PlatformResource right,) {  final leftCurrencies = _budgetCurrencies(left).toSet();  final rightCurrencies = _budgetCurrencies(right).toSet();  if (leftCurrencies.isEmpty) {    return rightCurrencies.toList();  }  if (rightCurrencies.isEmpty) {    return leftCurrencies.toList();  }   return leftCurrencies.intersection(rightCurrencies).toList()..sort();} Money _parseMoney(String value, String currency) {  final parsed = double.tryParse(value.replaceAll(',', '.')) ?? 0;  return Money(currency: currency, minorUnits: (parsed * 100).round());} String? _textValue(Map<String, dynamic> json, List<String> keys) {  for (final key in keys) {    final value = json[key];    if (value != null && value.toString().trim().isNotEmpty) {      return value.toString().trim();    }  }   return null;} List<String> _listTextValue(Map<String, dynamic> json, List<String> keys) {  for (final key in keys) {    final value = json[key];    if (value is List) {      return value          .map((item) => item.toString().trim())          .where((item) => item.isNotEmpty)          .toList();    }  }   return const [];} List<Map<String, dynamic>> _listMapValue(  Map<String, dynamic> json,  List<String> keys,) {  for (final key in keys) {    final value = json[key];    if (value is List) {      return value          .whereType<Map>()          .map(            (item) => item.map(              (key, value) => MapEntry(key.toString(), value),            ),          )          .toList();    }  }   return const [];} String _limitsSummary(CardLimitsInfo? info) {  if (info == null) return 'Set daily, weekly and monthly limits';  final parts = <String>[    for (final period in info.periods)      if (period.current != null)        '${period.label} ${cardLimitLabel(info.currency, period.current!)}',  ];  if (parts.isEmpty) {    return info.hasCaps        ? 'No limits set ยท tier ceilings apply'        : 'Set daily, weekly and monthly limits';  }  return parts.join(' ยท ');} /// Use the same API timestamp parser as Activity and receipts. Its compatibility/// fallback is deliberately not displayed when the response has no date.String _cardPreviewTime(PlatformResource resource) {  final transaction = LedgerTransaction.fromJson(resource.metadata);  return transaction.hasBookedAt      ? DateFormat('d MMM yyyy ยท HH:mm').format(transaction.bookedAt.toLocal())      : 'Date unavailable';} /// Opens the existing balance flow directly from Home, with Top up selected.Future<void> showCardTopUp(  BuildContext context,  WidgetRef ref,  PaymentCard card,) =>    _showTopUpDialog(context, ref, card, showFeedback: true); Future<void> _showTopUpDialog(  BuildContext context,  WidgetRef ref,  PaymentCard card, {  // Which way the money is meant to go. Both directions land in the same  // dialog, so the control that opened it has to say which segment it  // meant; a row labelled "off card" that opens on "Top up" is a lie.  // White-label keeps the old default on both of its rows, so its dialog  // opens exactly as it does today.  _CardBalanceAction action = _CardBalanceAction.load,  bool showFeedback = false,}) async {  if (card.isEqualsMoney) {    await _showEqualsCardBudgetTopUp(      context,      ref,      card,      showFeedback: showFeedback,    );    return;  }   final currency = _cardCurrency(card);  var interlaceBalances = const <HoppaWalletAsset>[];  try {    // Reopening Manage balance must include newly deposited funds.    ref      ..invalidate(userAssetsProvider)      ..invalidate(userWalletsProvider)      ..invalidate(walletsProvider)      ..invalidate(hoppaWalletAssetsProvider);    interlaceBalances = await ref.read(hoppaWalletAssetsProvider.future);  } catch (_) {    // The top-up action remains available when balances cannot be refreshed;    // the provider still validates available funds before execution.  }  if (!context.mounted) {    return;  }  final submission = await _showCardTopUpDialog(    context,    ref,    card: card,    currency: currency,    cardBalance: card.balance,    interlaceBalances: interlaceBalances,    initialAction: action,  );   if (submission == null || !context.mounted) {    return;  }  await Future<void>.delayed(const Duration(milliseconds: 120));  if (!context.mounted) {    return;  }   if (submission.action == _CardBalanceAction.unload) {    await ref.read(platformActionControllerProvider.notifier).run(          (api) => api.unloadCard(cardId: card.id, amount: submission.amount),        );  } else {    await ref        .read(bankingActionControllerProvider.notifier)        .topUpCard(cardId: card.id, amount: submission.amount, token: 'USD');  }  if (!context.mounted) return;  if (showFeedback) {    _showHomeBalanceResult(      context,      submission.action == _CardBalanceAction.unload          ? ref.read(platformActionControllerProvider).error          : ref.read(bankingActionControllerProvider).error,    );  }  ref    ..invalidate(portfolioEstimateProvider)    ..invalidate(cardDetailProvider(card.id))    ..invalidate(cardTransactionsProvider(card.id))    ..invalidate(cardsProvider)    ..invalidate(userAssetsProvider)    ..invalidate(userWalletsProvider)    ..invalidate(walletsProvider)    ..invalidate(hoppaWalletAssetsProvider)    ..invalidate(dashboardProvider);} Future<void> _showEqualsCardBudgetTopUp(  BuildContext context,  WidgetRef ref,  PaymentCard card, {  bool showFeedback = false,}) async {  List<PlatformResource> budgets;  try {    budgets = _equalsBudgetAccounts(await ref.read(budgetsProvider.future));  } catch (error) {    if (context.mounted) {      ScaffoldMessenger.of(        context,      ).showSnackBar(SnackBar(content: Text(friendlyErrorMessage(error))));    }    return;  }   final accountBalanceOnly =      budgets.length == 1 && _isEqualsMainBudget(budgets.first);  if (accountBalanceOnly) {    if (context.mounted) {      ScaffoldMessenger.of(context).showSnackBar(        SnackBar(          content: Text(            context.tr(                'No top-up is needed. Account Balance funds are already accessible by the card.'),          ),        ),      );    }    return;  }   final targetBudget = _targetBudgetForCard(card, budgets);  if (targetBudget == null) {    if (context.mounted) {      ScaffoldMessenger.of(context).showSnackBar(        SnackBar(          content:              Text(context.tr('This fiat card is missing its linked budget.')),        ),      );    }    return;  }   final sourceBudgets = budgets      .where((budget) => _budgetId(budget) != _budgetId(targetBudget))      .toList();  if (sourceBudgets.isEmpty) {    if (context.mounted) {      ScaffoldMessenger.of(context).showSnackBar(        SnackBar(          content: Text(            context.tr(                'No top-up is needed. This card already uses the only available budget.'),          ),        ),      );    }    return;  }  if (!context.mounted) {    return;  }   final submission = await _showBudgetTransferDialog(    context,    targetBudget: targetBudget,    sourceBudgets: sourceBudgets,    fallbackCurrency: _cardCurrency(card),  );  if (submission == null || !context.mounted) {    return;  }   await ref.read(platformActionControllerProvider.notifier).run(        (api) => api.transferBudget(          fromBudgetId: submission.sourceBudgetId,          toBudgetId: _budgetId(targetBudget),          amount: submission.amount,        ),      );  if (!context.mounted) return;  if (showFeedback) {    _showHomeBalanceResult(      context,      ref.read(platformActionControllerProvider).error,    );  }  ref    ..invalidate(portfolioEstimateProvider)    ..invalidate(cardDetailProvider(card.id))    ..invalidate(cardTransactionsProvider(card.id))    ..invalidate(cardsProvider)    ..invalidate(dashboardProvider)    ..invalidate(budgetsProvider);} Future<_CardTopUpSubmission?> _showCardTopUpDialog(  BuildContext context,  WidgetRef ref, {  required PaymentCard card,  required String currency,  required Money cardBalance,  required List<HoppaWalletAsset> interlaceBalances,  _CardBalanceAction initialAction = _CardBalanceAction.load,}) {  return showDialog<_CardTopUpSubmission>(    context: context,    builder: (context) => _CardTopUpDialog(      card: card,      initialAction: initialAction,      currency: currency,      cardBalance: cardBalance,      interlaceBalances: interlaceBalances,      estimateTopUp: (amount) => ref          .read(mobilePlatformApiProvider)          .getQuantumTopUpEstimate(amount: amount),    ),  );} // The Home shortcut has no card-screen action listeners to report a result.void _showHomeBalanceResult(BuildContext context, Object? error) {  ScaffoldMessenger.of(context).showSnackBar(    SnackBar(      content: Text(        error == null            ? context.tr('Card balance updated')            : friendlyErrorMessage(error),      ),    ),  );}
HOPPA DEVELOPER ASSISTANT

What are you building?

From a question to an implementation.

Get a step-by-step flow, example code and the docs behind it. Choose a starting point or describe your feature.

Answers use the public API guide and example app code. Check the linked reference before shipping. Explore code sources โ†—

Your question and recent conversation are sent to our AI provider. Donโ€™t include API keys, personal data or card details.