← Code knowledge base
Reviewed Flutter implementation · 2026-10-27

order_card_screen.dart

mobile_flutter/lib/features/cards/presentation/order_card_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–2484

import 'package:mobile_flutter/core/l10n/app_localizations.dart';import 'package:flutter/material.dart';import 'package:flutter_riverpod/flutter_riverpod.dart';import 'package:go_router/go_router.dart'; import '../../../app/routes.dart';import '../../../app/shell/banking_shell.dart';import '../../../brands/cardeo/cardeo_colors.dart';import '../../../brands/cardeo/cardeo_tilt.dart';import '../../../brands/cardeo/cardeo_glass_button.dart';import '../../../brands/cardeo/cardeo_ui.dart';import '../../../core/branding/app_design.dart';import '../../../flavors.dart';import '../../../core/models/banking_models.dart';import '../../../core/models/card_discount.dart';import '../../../core/models/equals_money.dart';import '../../../core/models/platform_models.dart';import '../../../core/widgets/app_states.dart';import '../../../shared/shared.dart';import '../../banking/application/banking_providers.dart';import '../../platform/application/platform_providers.dart';import '../../platform/presentation/tier_details_sheet.dart';import '../../signup/presentation/legal_agreements.dart';import 'card_order_failure.dart';import 'widgets/card_face.dart';import 'widgets/neo_bank_card.dart';import '../../../shared/widgets/app_progress_indicator.dart'; class OrderCardScreen extends ConsumerStatefulWidget {  const OrderCardScreen({super.key});   @override  ConsumerState<OrderCardScreen> createState() => _OrderCardScreenState();} class _OrderCardScreenState extends ConsumerState<OrderCardScreen> {  PlatformResource? _resolvedTier;  final _formKey = GlobalKey<FormState>();  final _labelController = TextEditingController(text: 'New card');  final _addressLine1Controller = TextEditingController();  final _cityController = TextEditingController();  final _stateController = TextEditingController();  final _countryController = TextEditingController(text: 'US');  final _postalCodeController = TextEditingController();  final _budgetNameController = TextEditingController();  final _discountController = TextEditingController();  CardDiscount? _discount;  String? _appliedDiscountCode;  String? _discountError;  bool _validatingDiscount = false;  int _discountRequest = 0;  int _selectedCardIndex = 0;  String? _selectedBudgetId;  int _selectedSkinIndex = 0;  bool _issuerCheckStarted = false;   @override  void initState() {    super.initState();    // A previous visit may have cached approval. Keep the form hidden until    // this route starts a fresh issuer lookup, after the widget tree is built.    Future.microtask(() {      if (!mounted) return;      ref.invalidate(kycDetailedStatusProvider);      setState(() => _issuerCheckStarted = true);    });    // Repaint preview as user types the label.    _labelController.addListener(() => setState(() {}));  }   @override  void dispose() {    _labelController.dispose();    _addressLine1Controller.dispose();    _cityController.dispose();    _stateController.dispose();    _countryController.dispose();    _postalCodeController.dispose();    _budgetNameController.dispose();    _discountController.dispose();    super.dispose();  }   @override  Widget build(BuildContext context) {    final actionState = ref.watch(bankingActionControllerProvider);    final platformActionState = ref.watch(platformActionControllerProvider);    final currentTier = ref.watch(currentTierProvider);    final tiers = ref.watch(tiersProvider);    final budgets = ref.watch(budgetsProvider);     ref.listen(bankingActionControllerProvider, (previous, next) {      next.whenOrNull(        data: (_) {          if ((previous?.isLoading != true || previous?.hasValue != true)) {            return;          }          ScaffoldMessenger.of(context).showSnackBar(            SnackBar(content: Text(context.tr('Card ordered'))),          );          context.go('/cards');        },        error: (error, stackTrace) => _showOrderFailure(error),      );    });     ref.listen(platformActionControllerProvider, (previous, next) {      next.whenOrNull(        data: (result) {          if ((previous?.isLoading == true && previous?.hasValue == true) &&              result != null) {            ScaffoldMessenger.of(context).showSnackBar(              SnackBar(content: Text(result.message)),            );          }        },        error: (error, stackTrace) =>            ScaffoldMessenger.of(context).showSnackBar(          SnackBar(content: Text(friendlyErrorMessage(error))),        ),      );    });     final issuerStatus = ref.watch(kycDetailedStatusProvider);    if (!_issuerCheckStarted ||        issuerStatus.isLoading ||        issuerStatus.hasError ||        issuerStatus.valueOrNull?.interlaceKycApproved != true) {      return Scaffold(        appBar: AppBar(title: Text(context.tr('Order card'))),        body: !_issuerCheckStarted || issuerStatus.isLoading            ? LoadingState(label: context.tr('Checking card verification'))            : issuerStatus.hasError                ? ErrorState(                    error: issuerStatus.error!,                    title: 'Unable to check card verification',                    onRetry: () => ref.invalidate(kycDetailedStatusProvider),                  )                : EmptyState(                    title: 'Card verification pending',                    message:                        'Interlace must approve your verification before you can order a card.',                    icon: Icons.verified_user_outlined,                    action: FilledButton(                      onPressed: () =>                          ref.invalidate(kycDetailedStatusProvider),                      child: Text(context.tr('Refresh status')),                    ),                  ),      );    }     return Scaffold(      appBar: AppBar(title: Text(context.tr('Order card'))),      body: currentTier.when(        data: (tier) {          if (tier == null || _tierId(tier) == null) {            return _NoTierSelected(tiers: tiers);          }          final tierId = _tierId(tier)!.toString();          final cardTier = ref.watch(cardTierProvider(tierId));           Widget orderForm(PlatformResource resolvedTier) {            _resolvedTier = resolvedTier;            final options = _cardOptionsForTier(resolvedTier);            if (_selectedCardIndex >= options.length) {              _selectedCardIndex = 0;            }             var stepIndex = 0;            String nextStep() => '${++stepIndex}';            final issuerDesign = options.isNotEmpty &&                options[_selectedCardIndex].artworkUrl.isNotEmpty;            final wide = context.isCardeoTheme &&                MediaQuery.sizeOf(context).width >= CardeoBreakpoints.desktop;            final padding = EdgeInsets.fromLTRB(              wide ? 40 : 16,              wide ? 24 : 12,              wide ? 40 : 16,              _bottomNavigationClearance(context),            );            final heroChildren = <Widget>[              // Live preview hero. Repaints on label/skin/option change.              _OrderPreviewHero(                label: _labelController.text.trim().isEmpty                    ? context.tr('New card')                    : _labelController.text,                option: options.isEmpty ? null : options[_selectedCardIndex],                skin: NeoCardSkin                    .all[_selectedSkinIndex % NeoCardSkin.all.length],              ),            ];            final stepChildren = <Widget>[              if (options.isEmpty)                EmptyState(                  title: context.tr('No card types in this tier'),                  message: context.tr(                      'Select another tier or contact support to enable card ordering.'),                  icon: Icons.credit_card_off_outlined,                )              else ...[                _SectionLabel(                  step: nextStep(),                  title: context.tr('Choose your card'),                  subtitle: context.tr('{p0} option{p1} in {p2}', {                    'p0': options.length,                    'p1': options.length == 1 ? '' : 's',                    'p2': _tierTitle(resolvedTier)                  }),                ),                const SizedBox(height: 10),                SizedBox(                  height: 132,                  child: ListView.separated(                    scrollDirection: Axis.horizontal,                    itemCount: options.length,                    separatorBuilder: (_, __) => const SizedBox(width: 10),                    itemBuilder: (context, index) => _CardTypePickerTile(                      option: options[index],                      selected: index == _selectedCardIndex,                      onTap: () => setState(() => _selectedCardIndex = index),                    ),                  ),                ),                const SizedBox(height: 12),                _discountField(actionState.isLoading),                const SizedBox(height: 12),                _CostsPanel(                  option: options[_selectedCardIndex],                  tier: resolvedTier,                  discount: _discount,                ),                // The issuer's own artwork is the only design for that                // card, so the design step only appears when there is a                // choice to make.                if (!issuerDesign) ...[                  const SizedBox(height: 22),                  _SectionLabel(                    step: nextStep(),                    title: context.tr('Pick a design'),                    subtitle: context.tr(                        'A fallback preview is used because the issuer did not supply artwork.'),                  ),                  const SizedBox(height: 10),                  _SkinPicker(                    selectedIndex: _selectedSkinIndex,                    onSelect: (i) => setState(() => _selectedSkinIndex = i),                  ),                ],                const SizedBox(height: 22),                _SectionLabel(                  step: nextStep(),                  title: context.tr('Name your card'),                  subtitle: context.tr('A label that shows up in your wallet.'),                ),                const SizedBox(height: 10),                TextFormField(                  controller: _labelController,                  textInputAction: TextInputAction.next,                  decoration: InputDecoration(                    labelText: context.tr('Card label'),                    hintText:                        context.tr('e.g. Daily spend, Travel, Subscriptions'),                    prefixIcon: const Icon(Icons.badge_outlined),                  ),                  validator: (value) {                    if (value == null || value.trim().isEmpty) {                      return context.tr('Enter a card label');                    }                    return null;                  },                ),                if (options[_selectedCardIndex].isEqualsMoney) ...[                  const SizedBox(height: 22),                  _SectionLabel(                    step: nextStep(),                    title: context.tr('Link a budget'),                    subtitle:                        context.tr('Fiat cards must be linked to a budget.'),                  ),                  const SizedBox(height: 10),                  budgets.when(                    data: (items) => _EqualsBudgetSelector(                      budgets: _equalsBudgetAccounts(items),                      cardCurrency: options[_selectedCardIndex].currency,                      selectedBudgetId: _selectedBudgetId,                      isBusy: platformActionState.isLoading,                      onChanged: (value) =>                          setState(() => _selectedBudgetId = value),                      onCreateBudget: () => _showCreateBudgetDialog(                        options[_selectedCardIndex].currency,                      ),                    ),                    error: (error, stackTrace) => ErrorState(                      error: error,                      onRetry: () => ref.invalidate(budgetsProvider),                    ),                    loading: () =>                        LoadingState(label: context.tr('Loading budgets')),                  ),                ],                if (!options[_selectedCardIndex].isVirtual) ...[                  const SizedBox(height: 22),                  _SectionLabel(                    step: options[_selectedCardIndex].isEqualsMoney ? '5' : '4',                    title: context.tr('Shipping address'),                    subtitle: context.tr('Where should we deliver your card?'),                  ),                  const SizedBox(height: 10),                  TextFormField(                    controller: _addressLine1Controller,                    decoration: InputDecoration(                      labelText: context.tr('Address line 1'),                      prefixIcon: const Icon(Icons.home_outlined),                    ),                    validator: (value) => value == null || value.trim().isEmpty                        ? 'Enter address line 1'                        : null,                  ),                  const SizedBox(height: 12),                  TextFormField(                    controller: _cityController,                    decoration: InputDecoration(                      labelText: context.tr('City'),                      prefixIcon: const Icon(Icons.location_city_outlined),                    ),                    validator: (value) => value == null || value.trim().isEmpty                        ? 'Enter city'                        : null,                  ),                  const SizedBox(height: 12),                  Row(                    children: [                      Expanded(                        child: TextFormField(                          controller: _stateController,                          decoration: InputDecoration(                            labelText: context.tr('State'),                          ),                          validator: (value) =>                              value == null || value.trim().isEmpty                                  ? 'Enter state'                                  : null,                        ),                      ),                      const SizedBox(width: 12),                      Expanded(                        child: TextFormField(                          controller: _countryController,                          textCapitalization: TextCapitalization.characters,                          decoration: InputDecoration(                            labelText: context.tr('Country'),                          ),                          validator: (value) =>                              value == null || value.trim().length != 2                                  ? 'Use ISO code'                                  : null,                        ),                      ),                    ],                  ),                  const SizedBox(height: 12),                  TextFormField(                    controller: _postalCodeController,                    decoration: InputDecoration(                      labelText: context.tr('Postal code'),                      prefixIcon: const Icon(Icons.markunread_mailbox_outlined),                    ),                    validator: (value) => value == null || value.trim().isEmpty                        ? 'Enter postal code'                        : null,                  ),                ],                const SizedBox(height: 28),                if (!options[_selectedCardIndex].canOrder) ...[                  EmptyState(                    title: context.tr('Card type unavailable'),                    message: context.tr(                        'This card type is missing its ordering reference. Select another card type or contact support.'),                    icon: Icons.credit_card_off_outlined,                  ),                  const SizedBox(height: 16),                ],                Builder(                  builder: (context) {                    final busy = actionState.isLoading;                    final blocked = busy ||                        _validatingDiscount ||                        platformActionState.isLoading ||                        !options[_selectedCardIndex].canOrder;                    final label = context.tr('Confirm & order {p0}',                        {'p0': options[_selectedCardIndex].name});                    if (!context.isCardeoTheme) {                      return SizedBox(                        width: double.infinity,                        child: FilledButton.icon(                          style: FilledButton.styleFrom(                            padding: const EdgeInsets.symmetric(vertical: 16),                            shape: RoundedRectangleBorder(                              borderRadius: BorderRadius.circular(16),                            ),                          ),                          onPressed: blocked                              ? null                              : () => _confirmAndSubmit(                                    options[_selectedCardIndex],                                  ),                          icon: busy                              ? const SizedBox.square(                                  dimension: 18,                                  child: AppProgressIndicator(strokeWidth: 2),                                )                              : const Icon(Icons.lock_outline),                          label: Text(                            label,                            style: const TextStyle(fontWeight: FontWeight.w700),                          ),                        ),                      );                    }                    // The one moment of the order flow. The glass CTA holds                    // its silhouette and position while the request is in                    // flight instead of swapping to a spinner-shaped button,                    // and a screen reader hears "…, in progress".                    return SizedBox(                      width: double.infinity,                      child: CardeoGlassButton(                        label: label,                        icon: Icons.lock_outline,                        loading: busy,                        onPressed: blocked                            ? null                            : () => _confirmAndSubmit(                                  options[_selectedCardIndex],                                ),                      ),                    );                  },                ),              ],            ];            if (wide) {              // Desktop: the live preview and tier stay on the left while the              // form steps sit in a panel on the right, like the card pages.              return Form(                key: _formKey,                child: SingleChildScrollView(                  padding: padding,                  child: Center(                    child: ConstrainedBox(                      constraints: const BoxConstraints(maxWidth: 1180),                      // Both columns share one height: the tier panel grows                      // to meet the bottom edge of the steps panel.                      child: IntrinsicHeight(                        child: Row(                          crossAxisAlignment: CrossAxisAlignment.start,                          children: [                            Expanded(                              flex: 5,                              child: Column(                                crossAxisAlignment: CrossAxisAlignment.stretch,                                children: heroChildren,                              ),                            ),                            const SizedBox(width: 28),                            Expanded(                              flex: 6,                              child: CardeoGlassPanel(                                radius: 24,                                borderAlpha: .22,                                padding:                                    const EdgeInsets.fromLTRB(26, 24, 26, 26),                                child: Column(                                  crossAxisAlignment:                                      CrossAxisAlignment.stretch,                                  children: stepChildren,                                ),                              ),                            ),                          ],                        ),                      ),                    ),                  ),                ),              );            }            return Form(              key: _formKey,              child: ListView(                padding: padding,                children: [                  ...heroChildren,                  const SizedBox(height: 22),                  ...stepChildren,                ],              ),            );          }           return tiers.when(            data: (items) {              final listTier = _tierWithCardOptions(tier, items);              return cardTier.when(                data: (detailTier) {                  if (_cardOptionsForTier(detailTier).isNotEmpty) {                    return orderForm(_mergeTierDisplay(listTier, detailTier));                  }                   return orderForm(listTier);                },                error: (error, stackTrace) => _cardOptionsForTier(listTier)                        .isEmpty                    ? ErrorState(                        error: error,                        onRetry: () => ref.invalidate(cardTierProvider(tierId)),                      )                    : orderForm(listTier),                loading: () => _cardOptionsForTier(listTier).isEmpty                    ? LoadingState(label: context.tr('Loading tier cards'))                    : orderForm(listTier),              );            },            error: (error, stackTrace) => _cardOptionsForTier(tier).isEmpty                ? ErrorState(                    error: error,                    onRetry: () => ref.invalidate(tiersProvider),                  )                : orderForm(tier),            loading: () => _cardOptionsForTier(tier).isEmpty                ? LoadingState(label: context.tr('Loading tier cards'))                : orderForm(tier),          );        },        error: (error, stackTrace) => ErrorState(          error: error,          onRetry: () => ref.invalidate(currentTierProvider),        ),        loading: () => LoadingState(label: context.tr('Loading current tier')),      ),    );  }   double _bottomNavigationClearance(BuildContext context) {    return MediaQuery.paddingOf(context).bottom + 148;  }   Widget _discountField(bool ordering) {    final content = Column(      crossAxisAlignment: CrossAxisAlignment.stretch,      children: [        Text(context.tr('Have a discount code?'),            style: Theme.of(context).textTheme.titleSmall),        const SizedBox(height: 8),        Row(          crossAxisAlignment: CrossAxisAlignment.start,          children: [            Expanded(              child: TextFormField(                key: const ValueKey('card-discount-code'),                controller: _discountController,                enabled: !ordering,                textCapitalization: TextCapitalization.characters,                autocorrect: false,                decoration:                    InputDecoration(labelText: context.tr('Discount code')),                onChanged: (_) => setState(() {                  // Invalidate both the applied code and any older request.                  _discountRequest++;                  _validatingDiscount = false;                  _discount = null;                  _appliedDiscountCode = null;                  _discountError = null;                }),                onFieldSubmitted: (_) {                  if (!_validatingDiscount && !ordering) _applyDiscount();                },              ),            ),            const SizedBox(width: 8),            OutlinedButton(              onPressed: ordering ||                      _validatingDiscount ||                      _discountController.text.trim().isEmpty                  ? null                  : _applyDiscount,              child:                  Text(context.tr(_validatingDiscount ? 'Applying…' : 'Apply')),            ),          ],        ),        if (_discountError != null || _appliedDiscountCode != null) ...[          const SizedBox(height: 8),          Semantics(            liveRegion: true,            child: Text(              _discountError ?? context.tr('Discount applied!'),              style: TextStyle(                  color: _discountError != null                      ? Theme.of(context).colorScheme.error                      : Theme.of(context).colorScheme.primary),            ),          ),        ],      ],    );    if (context.isCardeoTheme) {      return CardeoGlassPanel(        radius: 16,        padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),        child: content,      );    }    return NeoSurfaceCard(child: content);  }   Future<void> _applyDiscount() async {    final code = _discountController.text.trim().toUpperCase();    if (code.isEmpty) return;    final request = ++_discountRequest;    setState(() {      _validatingDiscount = true;      _discount = null;      _appliedDiscountCode = null;      _discountError = null;    });    try {      final result =          await ref.read(mobileBankingApiProvider).validateCardDiscount(code);      if (!mounted || request != _discountRequest) return;      setState(() {        _discount = result.isValid ? result : null;        _appliedDiscountCode = result.isValid ? code : null;        _discountError = result.isValid            ? null            : result.errorMessage ?? context.tr('Invalid discount code');      });    } catch (error) {      if (!mounted || request != _discountRequest) return;      setState(() => _discountError = friendlyErrorMessage(error));    } finally {      if (mounted && request == _discountRequest) {        setState(() => _validatingDiscount = false);      }    }  }   Future<void> _confirmAndSubmit(_OrderableCardType option) async {    if (_validatingDiscount) return;    if (!_formKey.currentState!.validate()) {      return;    }    if (!option.canOrder) {      ScaffoldMessenger.of(context).showSnackBar(        SnackBar(          content: Text(              context.tr('Select a card type that is available for ordering.')),        ),      );      return;    }    if (option.isEqualsMoney &&        (_selectedBudgetId == null || _selectedBudgetId!.trim().isEmpty)) {      ScaffoldMessenger.of(context).showSnackBar(        SnackBar(          content: Text(context.tr('Select a fiat budget for this card.')),        ),      );      return;    }     final config = ref.read(mobileTenantConfigProvider).valueOrNull;    final appName = AppDesignTheme.nameOf(context);    final specs = cardOrderAgreementSpecs(      config,      appName:          appName.isNotEmpty ? appName : AppBranding.fromEnvironment().appName,    );    final accepted = <LegalAgreementKey>{};    final confirmed = await showDialog<bool>(      context: context,      builder: (dialogContext) => StatefulBuilder(        builder: (dialogContext, setDialogState) {          final intro = Text(            context.tr('Check every detail before placing the order.'),            style: Theme.of(dialogContext).textTheme.bodyLarge?.copyWith(                  color: Theme.of(dialogContext).colorScheme.onSurfaceVariant,                ),          );          final preview = Center(            child: ConstrainedBox(              constraints: const BoxConstraints(maxWidth: 360),              child: CardeoTiltCard(                child: NeoBankCard(                  label: _labelController.text.trim().isEmpty                      ? context.tr('New card')                      : _labelController.text.trim(),                  last4: '••••',                  network: '',                  virtual: option.isVirtual,                  holderName: 'YOUR NAME',                  skin: NeoCardSkin                      .all[_selectedSkinIndex % NeoCardSkin.all.length],                  artworkUrl: option.artworkUrl,                  artworkAlt: option.artworkAlt,                  textColorHex: option.textColorHex,                ),              ),            ),          );          final summary = NeoSurfaceCard(            child: Column(              children: [                _SummaryRow(label: context.tr('Type'), value: option.name),                _SummaryRow(                  label: context.tr('Format'),                  value: option.isVirtual ? 'Virtual' : 'Physical',                ),                _SummaryRow(                    label: context.tr('Currency'), value: option.currency),                if (_appliedDiscountCode != null)                  _SummaryRow(                      label: context.tr('Discount code'),                      value: _appliedDiscountCode!),                if (!option.isVirtual)                  _SummaryRow(                    label: context.tr('Ships to'),                    value: [                      _addressLine1Controller.text.trim(),                      _cityController.text.trim(),                      _countryController.text.trim().toUpperCase(),                    ].where((part) => part.isNotEmpty).join(', '),                  ),              ],            ),          );          final costs = _CostsPanel(              option: option, tier: _resolvedTier, discount: _discount);          final agreements = LegalAgreementsSection(            specs: specs,            accepted: accepted,            onChanged: (key, value) => setDialogState(() {              if (value) {                accepted.add(key);              } else {                accepted.remove(key);              }            }),          );           return NeoFullScreenDialog(            title: context.tr('Review cost and agreements'),            // On wide web the review is two columns in a taller sheet, so            // every agreement is on screen beside the card and its costs            // instead of below them, past the fold.            wideWidth: _reviewWideWidth,            wideMaxHeight: 960,            body: LayoutBuilder(              builder: (context, constraints) {                if (constraints.maxWidth < _reviewTwoColumnWidth) {                  return ListView(                    padding: const EdgeInsets.all(20),                    children: [                      intro,                      const SizedBox(height: 20),                      preview,                      const SizedBox(height: 20),                      summary,                      const SizedBox(height: 12),                      costs,                      const SizedBox(height: 18),                      agreements,                    ],                  );                }                return ListView(                  padding: const EdgeInsets.all(24),                  children: [                    intro,                    const SizedBox(height: 20),                    Row(                      crossAxisAlignment: CrossAxisAlignment.start,                      children: [                        Expanded(                          child: Column(                            crossAxisAlignment: CrossAxisAlignment.stretch,                            children: [                              preview,                              const SizedBox(height: 20),                              summary,                              const SizedBox(height: 12),                              costs,                            ],                          ),                        ),                        const SizedBox(width: 24),                        Expanded(child: agreements),                      ],                    ),                  ],                );              },            ),            primaryLabel: context.tr('Accept & order card'),            primaryIcon: Icons.lock_outline,            onPrimary: allLegalAgreementsAccepted(specs, accepted)                ? () => Navigator.of(dialogContext).pop(true)                : null,          );        },      ),    );    if (confirmed != true || !mounted) return;    _submit(option, legalAgreementsPayload(specs, accepted, config));  }   /// An order the issuer could not charge gets a dialog that leads straight  /// to the two ways of finding the money: topping up the balance on the  /// crypto accounts screen, or unloading an existing card. Everything else  /// stays a plain notice.  void _showOrderFailure(Object error) {    final failure =        describeCardOrderFailure(error, AppLocalizations.of(context));    if (failure is! CardOrderInsufficientFunds) {      ScaffoldMessenger.of(context).showSnackBar(        SnackBar(content: Text(failure.message)),      );      return;    }     showDialog<void>(      context: context,      builder: (dialogContext) => AlertDialog(        title: Text(context.tr('Not enough balance')),        content: Text(failure.message),        actions: [          TextButton(            onPressed: () => Navigator.of(dialogContext).pop(),            child: Text(context.tr('Back')),          ),          TextButton(            onPressed: () {              Navigator.of(dialogContext).pop();              context.go(AppRoutes.cards);            },            child: Text(context.tr('Unload card')),          ),          FilledButton(            onPressed: () {              Navigator.of(dialogContext).pop();              context.go(AppRoutes.walletBalances);            },            child: Text(context.tr('Top up balance')),          ),        ],      ),    );  }   void _submit(    _OrderableCardType option,    Map<String, dynamic> legalAgreements,  ) {    if (!_formKey.currentState!.validate()) {      return;    }    if (!option.canOrder) {      ScaffoldMessenger.of(context).showSnackBar(        SnackBar(          content: Text(              context.tr('Select a card type that is available for ordering.')),        ),      );      return;    }    if (option.isEqualsMoney &&        (_selectedBudgetId == null || _selectedBudgetId!.trim().isEmpty)) {      ScaffoldMessenger.of(context).showSnackBar(        SnackBar(          content: Text(context.tr('Select a fiat budget for this card.')),        ),      );      return;    }     ref.read(bankingActionControllerProvider.notifier).orderCard(          label: _labelController.text.trim(),          virtual: option.isVirtual,          currency: option.currency,          cardTypeId: option.cardTypeId,          productCode: option.productCode,          addressLine1:              option.isVirtual ? null : _addressLine1Controller.text.trim(),          city: option.isVirtual ? null : _cityController.text.trim(),          state: option.isVirtual ? null : _stateController.text.trim(),          country: option.isVirtual              ? null              : _countryController.text.trim().toUpperCase(),          postalCode:              option.isVirtual ? null : _postalCodeController.text.trim(),          budgetId: option.isEqualsMoney ? _selectedBudgetId : null,          discountCode: _appliedDiscountCode,          legalAgreements: legalAgreements,        );  }   Future<void> _showCreateBudgetDialog(String currency) async {    final normalizedCurrency = currency.trim().toUpperCase();    _budgetNameController.text = '$normalizedCurrency Card Budget';    final selectedCurrencies = <String>{normalizedCurrency};     final confirmed = await showDialog<bool>(      context: context,      builder: (context) => StatefulBuilder(        builder: (context, setDialogState) => NeoFullScreenDialog(          title: context.tr('Create budget'),          body: Padding(            padding: const EdgeInsets.all(20),            child: Column(              children: [                TextField(                  controller: _budgetNameController,                  decoration: InputDecoration(                    labelText: context.tr('Budget name'),                    helperText: context.tr('{p0} is required for this card.',                        {'p0': normalizedCurrency}),                  ),                ),                const SizedBox(height: 12),                Align(                  alignment: Alignment.centerLeft,                  child: Text(                    context.tr('Currencies'),                    style: Theme.of(context).textTheme.labelLarge,                  ),                ),                const SizedBox(height: 6),                Expanded(                  child: _EqualsCurrencyChecklist(                    selectedCurrencies: selectedCurrencies,                    lockedCurrency: normalizedCurrency,                    onChanged: (currency, selected) {                      setDialogState(() {                        if (selected) {                          selectedCurrencies.add(currency);                        } else if (currency != normalizedCurrency) {                          selectedCurrencies.remove(currency);                        }                      });                    },                  ),                ),              ],            ),          ),          primaryLabel: 'Create budget',          primaryIcon: Icons.add_rounded,          onPrimary: selectedCurrencies.isEmpty              ? null              : () => Navigator.of(context).pop(true),        ),      ),    );     final name = _budgetNameController.text.trim();    if (confirmed != true || name.isEmpty || !mounted) {      return;    }     try {      final result = await ref.read(mobilePlatformApiProvider).createBudget(            name: name,            currencies: _orderedSelectedCurrencies(selectedCurrencies),          );      ref.invalidate(budgetsProvider);      final budgetId = _textValue(result.metadata, const [        'budgetId',        'BudgetId',        'accountId',        'AccountId',        'id',        'Id',      ]);      if (budgetId != null) {        setState(() => _selectedBudgetId = budgetId);      }      if (mounted) {        ScaffoldMessenger.of(context).showSnackBar(          SnackBar(content: Text(result.message)),        );      }    } catch (error) {      if (mounted) {        ScaffoldMessenger.of(context).showSnackBar(          SnackBar(content: Text(friendlyErrorMessage(error))),        );      }    }  }} class _EqualsBudgetSelector extends StatelessWidget {  const _EqualsBudgetSelector({    required this.budgets,    required this.cardCurrency,    required this.selectedBudgetId,    required this.isBusy,    required this.onChanged,    required this.onCreateBudget,  });   final List<PlatformResource> budgets;  final String cardCurrency;  final String? selectedBudgetId;  final bool isBusy;  final ValueChanged<String?> onChanged;  final VoidCallback onCreateBudget;   @override  Widget build(BuildContext context) {    final normalizedCurrency = cardCurrency.trim().toUpperCase();    final compatible = budgets        .where(            (budget) => _budgetCurrencies(budget).contains(normalizedCurrency))        .toList();    final selectableBudgets = compatible.isEmpty ? budgets : compatible;    final selected =        selectableBudgets.any((budget) => _budgetId(budget) == selectedBudgetId)            ? selectedBudgetId            : null;     return Card(      child: Padding(        padding: const EdgeInsets.all(16),        child: Column(          crossAxisAlignment: CrossAxisAlignment.start,          children: [            Text(              context.tr('Assign this card to a budget'),              style: Theme.of(context).textTheme.titleMedium,            ),            const SizedBox(height: 6),            Text(              context.tr('Fiat cards must be linked to a spending budget.'),              style: Theme.of(context).textTheme.bodyMedium,            ),            const SizedBox(height: 12),            if (selectableBudgets.isEmpty)              EmptyState(                title: context.tr('No budgets available'),                message: context                    .tr('Create a budget first, then continue ordering.'),                icon: Icons.savings_outlined,              )            else              DropdownButtonFormField<String>(                initialValue: selected,                decoration: InputDecoration(labelText: context.tr('Budget')),                items: [                  for (final budget in selectableBudgets)                    DropdownMenuItem(                      value: _budgetId(budget),                      child: Text(_budgetTitle(budget)),                    ),                ],                onChanged: isBusy ? null : onChanged,                validator: (value) =>                    value == null || value.isEmpty ? 'Select a budget' : null,              ),            const SizedBox(height: 8),            Align(              alignment: Alignment.centerLeft,              child: OutlinedButton.icon(                onPressed: isBusy ? null : onCreateBudget,                icon: const Icon(Icons.add),                label: Text(context.tr('Create budget')),              ),            ),          ],        ),      ),    );  }} class _EqualsCurrencyChecklist extends StatelessWidget {  const _EqualsCurrencyChecklist({    required this.selectedCurrencies,    required this.onChanged,    this.lockedCurrency,  });   final Set<String> selectedCurrencies;  final String? lockedCurrency;  final void Function(String currency, bool selected) onChanged;   @override  Widget build(BuildContext context) {    final locked = lockedCurrency?.trim().toUpperCase();     return DecoratedBox(      decoration: BoxDecoration(        border: Border.all(color: Theme.of(context).dividerColor),        borderRadius: BorderRadius.circular(8),      ),      child: ListView.builder(        itemCount: equalsSupportedCurrencyCodes.length,        itemBuilder: (context, index) {          final currency = equalsSupportedCurrencyCodes[index];          final selected = selectedCurrencies.contains(currency);          final lockedSelected = currency == locked;           return CheckboxListTile(            dense: true,            value: selected,            onChanged: lockedSelected                ? null                : (value) => onChanged(currency, value == true),            title: Text(currency),            subtitle: lockedSelected ? Text(context.tr('Required')) : null,            controlAffinity: ListTileControlAffinity.leading,          );        },      ),    );  }} /// Width of the order review on wide web, and the body width from which it/// sets the agreements beside the card instead of under it.const double _reviewWideWidth = 960;const double _reviewTwoColumnWidth = 820; class _NoTierSelected extends ConsumerWidget {  const _NoTierSelected({required this.tiers});   final AsyncValue<List<PlatformResource>> tiers;   @override  Widget build(BuildContext context, WidgetRef ref) {    return ListView(      padding: const EdgeInsets.all(16),      children: [        EmptyState(          title: context.tr('Select a tier first'),          message: context.tr(              'Choose a subscription tier to unlock eligible card types and limits.'),          icon: Icons.workspace_premium_outlined,        ),        const SizedBox(height: 16),        Text(context.tr('Available tiers'),            style: Theme.of(context).textTheme.titleLarge),        const SizedBox(height: 8),        tiers.when(          data: (items) => items.isEmpty              ? EmptyState(                  title: context.tr('No tiers available'),                  message: context                      .tr('Tier options will appear here when available.'),                  icon: Icons.workspace_premium_outlined,                )              : Column(                  children: [                    for (final tier in items) _TierChoiceCard(tier: tier),                  ],                ),          error: (error, stackTrace) => ErrorState(            error: error,            onRetry: () => ref.invalidate(tiersProvider),          ),          loading: () => LoadingState(label: context.tr('Loading tiers')),        ),      ],    );  }} class _TierChoiceCard extends ConsumerWidget {  const _TierChoiceCard({required this.tier});   final PlatformResource tier;   @override  Widget build(BuildContext context, WidgetRef ref) {    final action = ref.watch(platformActionControllerProvider);     return Card(      child: ListTile(        leading: const Icon(Icons.workspace_premium_outlined),        title: Text(_tierTitle(tier)),        subtitle: Text(          _tierDetails(tier).isEmpty              ? context.tr('Review this tier before selecting it.')              : _tierDetails(tier).join(' • '),        ),        // The row opens what the plan includes; only the button picks it.        onTap: () => showTierDetailsSheet(          context,          tier: tier,          onChoose:              action.isLoading ? null : () => _selectTier(context, ref, tier),        ),        trailing: FilledButton(          onPressed:              action.isLoading ? null : () => _selectTier(context, ref, tier),          child: Text(context.tr('Select')),        ),      ),    );  }} class _OrderableCardType {  const _OrderableCardType({    required this.name,    required this.isVirtual,    required this.currency,    this.bankProvider = '',    this.cardTypeId,    this.productCode,    this.details = 'Available with current tier',    this.artworkUrl = '',    this.artworkAlt = '',    this.textColorHex = '',    this.issueFee,    this.monthlyFee,    this.yearlyFee,    this.feeCurrency = 'USD',    this.tierOverridesMonthly = false,    this.tierOverridesYearly = false,    this.replacementFee,    this.customerPaysProduction = false,    this.customerPaysShipping = false,  });   final String name;  final bool isVirtual;  final String currency;  final String bankProvider;  final int? cardTypeId;  final String? productCode;  final String details;  final String artworkUrl;  final String artworkAlt;  final String textColorHex;   /// Programme pricing as returned by the issuer; null means not listed.  final String? issueFee;  final String? monthlyFee;  final String? yearlyFee;  final String feeCurrency;   /// When set, the tier's monthly price replaces the card's own.  final bool tierOverridesMonthly;  final bool tierOverridesYearly;  final String? replacementFee;  final bool customerPaysProduction;  final bool customerPaysShipping;   bool get canOrder =>      cardTypeId != null || (productCode != null && productCode!.isNotEmpty);   bool get isEqualsMoney {    final normalized = bankProvider.toLowerCase().trim();    return normalized == 'equalsmoney' || normalized == 'equals money';  }} List<_OrderableCardType> _cardOptionsForTier(PlatformResource tier) {  final cards = _listFromMetadata(tier.metadata, const [    'availableCardTypes',    'AvailableCardTypes',    'cardTypes',    'CardTypes',    'cardBenefits',    'CardBenefits',    'cardTiers',    'CardTiers',    'cardTypeTiers',    'CardTypeTiers',    'cards',    'Cards',  ]);   return cards      .where((card) => _boolValue(card, const ['isActive', 'active']) != false)      .map(_cardOptionFromMap)      .whereType<_OrderableCardType>()      .toList();} PlatformResource _tierWithCardOptions(  PlatformResource currentTier,  List<PlatformResource> tiers,) {  if (_cardOptionsForTier(currentTier).isNotEmpty) {    return currentTier;  }   final currentTierId = _tierId(currentTier);  for (final tier in tiers) {    if (currentTierId != null && _tierId(tier) == currentTierId) {      return tier;    }  }   final currentTitle = _tierTitle(currentTier).toLowerCase();  for (final tier in tiers) {    if (_tierTitle(tier).toLowerCase() == currentTitle) {      return tier;    }  }   return currentTier;} PlatformResource _mergeTierDisplay(  PlatformResource displayTier,  PlatformResource cardOptionsTier,) {  return PlatformResource(    id: displayTier.id,    title: displayTier.title,    subtitle: displayTier.subtitle,    metadata: {      ...displayTier.metadata,      ...cardOptionsTier.metadata,      if (displayTier.metadata['tierName'] != null)        'tierName': displayTier.metadata['tierName'],      if (displayTier.metadata['TierName'] != null)        'TierName': displayTier.metadata['TierName'],      if (displayTier.metadata['name'] != null)        'name': displayTier.metadata['name'],      if (displayTier.metadata['Name'] != null)        'Name': displayTier.metadata['Name'],      if (displayTier.metadata['tierLevel'] != null)        'tierLevel': displayTier.metadata['tierLevel'],      if (displayTier.metadata['TierLevel'] != null)        'TierLevel': displayTier.metadata['TierLevel'],      if (displayTier.metadata['level'] != null)        'level': displayTier.metadata['level'],      if (displayTier.metadata['Level'] != null)        'Level': displayTier.metadata['Level'],    },  );} _OrderableCardType? _cardOptionFromMap(Map<String, dynamic> json) {  final nestedCardType = _mapValue(json, const ['cardType', 'CardType']);  final cardType = nestedCardType == null      ? json      : {          ...nestedCardType,          ...json,          'name': nestedCardType['name'] ?? nestedCardType['Name'],          'code': nestedCardType['code'] ?? nestedCardType['Code'],          'description':              nestedCardType['description'] ?? nestedCardType['Description'],          'features': nestedCardType['features'] ?? nestedCardType['Features'],          'monthlyFee':              nestedCardType['monthlyFee'] ?? nestedCardType['MonthlyFee'],          'issuanceFee':              nestedCardType['issuanceFee'] ?? nestedCardType['IssuanceFee'],          'replacementFee': nestedCardType['replacementFee'] ??              nestedCardType['ReplacementFee'],          'currencyCode':              nestedCardType['currencyCode'] ?? nestedCardType['CurrencyCode'],          'isVirtual':              nestedCardType['isVirtual'] ?? nestedCardType['IsVirtual'],          'isPhysical':              nestedCardType['isPhysical'] ?? nestedCardType['IsPhysical'],        };  final id = _intValue(json, const ['cardTypeId', 'CardTypeId']) ??      _intValue(json, const [        'cardTypeTierId',        'CardTypeTierId',      ]) ??      _intValue(cardType, const ['cardTypeTierId', 'CardTypeTierId']) ??      _intValue(cardType, const ['cardTypeId', 'CardTypeId', 'id', 'Id']);  final productCode = _textValue(cardType, const [    'productCode',    'ProductCode',    'code',    'Code',    'cardType',    'CardType',    'type',    'Type',  ]);  final name = _textValue(cardType, const [        'name',        'Name',        'displayName',        'DisplayName',        'cardTypeName',        'CardTypeName',        'cardType',        'CardType',        'productCode',        'ProductCode',      ]) ??      'Card type';  final normalized = '$name ${productCode ?? ''}'.toLowerCase();  final isPhysical =      _boolValue(cardType, const ['isPhysical', 'physical', 'IsPhysical']) ??          normalized.contains('physical');  final isVirtual = !isPhysical;  final fee = _textValue(cardType, const [    'monthlyFee',    'MonthlyFee',    'monthlySubscriptionFee',    'MonthlySubscriptionFee',  ]);  final yearlyFee = _textValue(cardType, const [    'yearlyFee',    'YearlyFee',    'yearlySubscriptionFee',    'YearlySubscriptionFee',  ]);  final dailyLimit = _textValue(cardType, const [    'dailyLimit',    'DailyLimit',    'dailySpendLimit',    'DailySpendLimit',  ]);  final monthlyLimit = _textValue(cardType, const [    'monthlyLimit',    'MonthlyLimit',    'monthlySpendLimit',    'MonthlySpendLimit',  ]);  final issueFee = _textValue(cardType, const [    'issueFee',    'IssueFee',    'issuanceFee',    'IssuanceFee',    'cardFee',    'CardFee',    'price',    'Price',  ]);  final tierOverridesMonthly = _boolValue(json, const [        'tierOverridesCardMonthly',        'TierOverridesCardMonthly',      ]) ??      _boolValue(cardType, const [        'tierOverridesCardMonthly',        'TierOverridesCardMonthly',      ]) ??      false;  final customerPaysShipping = _boolValue(cardType, const [        'customerPaysForShippment',        'CustomerPaysForShippment',        'customerPaysForShipment',        'CustomerPaysForShipment',      ]) ??      false;  final replacementFee = _textValue(cardType, const [    'replacementFee',    'ReplacementFee',  ]);  final currency = _textValue(cardType, const [    'currency',    'Currency',    'currencyCode',    'CurrencyCode',  ]);  final bankProvider = _bankProvider(cardType);  final artworkUrl = _normalizeCardArtworkUrl(_textValue(cardType, const [    'cardImageUrl',    'CardImageUrl',    'cardPreviewUrl',    'CardPreviewUrl',    'cardThumbnailUrl',    'CardThumbnailUrl',    'imageUrl',    'ImageUrl',  ]));  final artworkAlt = _textValue(cardType, const [        'cardImageAlt',        'CardImageAlt',        'imageAlt',        'ImageAlt',      ]) ??      '';  final textColorHex = _textValue(cardType, const [        'cardTextColor',        'CardTextColor',        'textColor',        'TextColor',      ]) ??      '';  final description = _textValue(cardType, const [    'cardDescription',    'CardDescription',    'description',    'Description',  ]);  final freeCards = _textValue(json, const [    'freeCardsIncluded',    'FreeCardsIncluded',  ]);  final maxCards = _textValue(json, const [    'maxCards',    'MaxCards',  ]);  final features = _displayListValues(cardType, const [    'cardFeatures',    'CardFeatures',    'features',    'Features',  ]);   return _OrderableCardType(    name: friendlyStatus(name),    isVirtual: isVirtual,    currency: currency ?? 'USD',    bankProvider: bankProvider,    cardTypeId: id,    productCode: productCode,    artworkUrl: artworkUrl,    artworkAlt: artworkAlt,    textColorHex: textColorHex,    issueFee: issueFee,    monthlyFee: fee,    yearlyFee: yearlyFee,    feeCurrency: currency ?? 'USD',    tierOverridesMonthly: tierOverridesMonthly,    tierOverridesYearly: _boolValue(cardType, const [          'tierOverridesCardYearly',          'TierOverridesCardYearly',        ]) ??        false,    replacementFee: replacementFee,    customerPaysProduction: _boolValue(cardType, const [          'customerPaysForProduction',          'CustomerPaysForProduction',        ]) ??        false,    customerPaysShipping: customerPaysShipping,    details: [      isVirtual ? 'Virtual' : 'Physical',      if (description != null) friendlyStatus(description),      if (freeCards != null) 'Free cards $freeCards',      if (maxCards != null) 'Max cards $maxCards',      if (monthlyLimit != null) 'Monthly limit $monthlyLimit',      if (dailyLimit != null) 'Daily limit $dailyLimit',      if (issueFee != null) 'Issue fee ${_moneyText(issueFee, currency)}',      if (replacementFee != null)        'Replacement fee ${_moneyText(replacementFee, currency)}',      if (fee != null) 'Monthly fee ${_moneyText(fee, currency)}',      if (yearlyFee != null) 'Yearly fee ${_moneyText(yearlyFee, currency)}',      if (bankProvider == 'equalsmoney') 'Fiat account',      ...features.map(friendlyStatus),      if (id == null && (productCode == null || productCode.isEmpty))        'Ordering reference missing',    ].join(' • '),  );} String _normalizeCardArtworkUrl(String? value) {  final normalized = value?.trim() ?? '';  final markdownLink =      RegExp(r'^\[[^\]]*\]\((https?://[^)]+)\)$').firstMatch(normalized);  return markdownLink?.group(1) ?? normalized;} List<PlatformResource> _equalsBudgetAccounts(List<PlatformResource> budgets) {  return budgets.where(_isEqualsBudgetAccount).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));    });} List<String> _orderedSelectedCurrencies(Set<String> selectedCurrencies) {  return [    for (final currency in equalsSupportedCurrencyCodes)      if (selectedCurrencies.contains(currency)) currency,  ];} bool _isEqualsBudgetAccount(PlatformResource budget) {  final provider = _textValue(budget.metadata, const [    'provider',    'Provider',    'bankProvider',    'BankProvider',  ])?.toLowerCase();  final providerType = _textValue(budget.metadata, const [    'providerType',    'ProviderType',    'bankProviderType',    'BankProviderType',  ]);  final isEqualsProvider = provider == 'equalsmoney' ||      provider == 'equals money' ||      providerType == '2';  final accountType = _textValue(budget.metadata, const [    'accountType',    'AccountType',    'type',    'Type',  ])?.toLowerCase();   return isEqualsProvider &&      (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();} String _bankProvider(Map<String, dynamic> cardType) {  final provider = _textValue(cardType, const [    'bankProvider',    'BankProvider',    'provider',    'Provider',    'providerName',    'ProviderName',  ]);  if (provider != null) {    final normalized = provider.toLowerCase().replaceAll(' ', '');    if (normalized == 'equalsmoney' || normalized == '2') {      return 'equalsmoney';    }    return normalized;  }   final providerType = _textValue(cardType, const [    'bankProviderType',    'BankProviderType',    'providerType',    'ProviderType',  ]);   return providerType == '2' ? 'equalsmoney' : '';} void _selectTier(BuildContext context, WidgetRef ref, PlatformResource tier) {  final tierId = _tierId(tier);  if (tierId == null) {    ScaffoldMessenger.of(context).showSnackBar(      SnackBar(        content: Text(context            .tr('This tier is missing the identifier needed to select it.')),      ),    );    return;  }   ref.read(platformActionControllerProvider.notifier).run(        (api) => api.selectTier(          tierId: tierId,          tierCycle: _tierCycle(tier),        ),      );} String _tierTitle(PlatformResource tier) {  final title = _textValue(tier.metadata, const [    'tierName',    'TierName',    'name',    'Name',    'displayName',    'DisplayName',  ]);  if (title != null && !_looksTechnicalId(title)) {    return friendlyStatus(title);  }   final fallback = tier.title.trim();  if (fallback.isNotEmpty && !_looksTechnicalId(fallback)) {    return friendlyStatus(fallback);  }   return 'Tier ${_tierId(tier) ?? ''}'.trim();} int? _tierId(PlatformResource tier) {  final value = _textValue(tier.metadata, const [        'tierId',        'TierId',        'selectedTierId',        'SelectedTierId',        'id',        'Id',      ]) ??      (tier.id.trim().isEmpty || tier.id == 'item' ? null : tier.id);   return value == null ? null : int.tryParse(value);} List<String> _tierDetails(PlatformResource tier) {  final metadata = tier.metadata;  final values = [    _textValue(metadata, const ['tierLevel', 'TierLevel', 'level', 'Level']),    _textValue(metadata, const ['maxCards', 'MaxCards']),    _textValue(metadata, const ['monthlyLimit', 'MonthlyLimit']),    _textValue(metadata, const ['dailyLimit', 'DailyLimit']),  ];  final labels = ['Level', 'Cards', 'Monthly', 'Daily'];   return [    for (var index = 0; index < values.length; index++)      if (values[index] != null) '${labels[index]} ${values[index]}',  ];} String? _tierCycle(PlatformResource tier) {  return _textValue(tier.metadata, const [    'tierCycle',    'TierCycle',    'billingCycle',    'BillingCycle',    'tierBillingCycle',    'TierBillingCycle',    'cycle',    'Cycle',  ]);} List<Map<String, dynamic>> _listFromMetadata(  Map<String, dynamic> metadata,  List<String> keys,) {  for (final key in keys) {    final value = metadata[key];    if (value is List) {      return value.whereType<Map>().map(_stringMap).toList();    }  }   final nestedData = metadata['data'] ?? metadata['Data'];  if (nestedData is Map) {    final nestedItems = _listFromMetadata(_stringMap(nestedData), keys);    if (nestedItems.isNotEmpty) {      return nestedItems;    }  }   final nestedList = metadata['list'] ?? metadata['List'];  if (nestedList is List) {    final listItems = nestedList.whereType<Map>().map(_stringMap).toList();    if (listItems.isNotEmpty) {      return listItems;    }  }   final nestedTier = metadata['tier'] ?? metadata['Tier'];  if (nestedTier is Map) {    return _listFromMetadata(_stringMap(nestedTier), keys);  }   return const [];} Map<String, dynamic>? _mapValue(Map<String, dynamic> json, List<String> keys) {  for (final key in keys) {    final value = json[key];    if (value is Map) {      return _stringMap(value);    }  }   return null;} Map<String, dynamic> _stringMap(Map value) {  return value.map((key, item) => MapEntry(key.toString(), item));} 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();    }  }   return null;} int? _intValue(Map<String, dynamic> json, List<String> keys) {  final value = _textValue(json, keys);  return value == null ? null : int.tryParse(value);} bool? _boolValue(Map<String, dynamic> json, List<String> keys) {  final value = _textValue(json, keys)?.toLowerCase();  if (value == 'true') {    return true;  }  if (value == 'false') {    return false;  }   return null;} String _moneyText(String amount, String? currency) {  if (currency == null || currency.trim().isEmpty) {    return amount;  }   return '$amount $currency';} List<String> _displayListValues(  Map<String, dynamic> metadata,  List<String> keys,) {  final values = <String>[];  for (final key in keys) {    _appendDisplayValues(values, metadata[key]);  }   return values;} 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 [];} void _appendDisplayValues(List<String> values, Object? value) {  if (value == null) {    return;  }  if (value is List) {    for (final item in value) {      _appendDisplayValues(values, item);    }    return;  }  if (value is Map) {    final json = value.map((key, item) => MapEntry(key.toString(), item));    final text = _textValue(json, const [      'name',      'Name',      'title',      'Title',      'label',      'Label',      'description',      'Description',      'feature',      'Feature',    ]);    if (text != null) {      values.add(text);    }    return;  }   values.add(value.toString());} bool _looksTechnicalId(String value) {  final normalized = value.trim();  if (normalized.isEmpty) {    return false;  }   return RegExp(r'^[a-z]+_[a-z0-9_]+$', caseSensitive: false)          .hasMatch(normalized) ||      RegExp(r'^[0-9a-f]{8}-[0-9a-f-]{27,}$', caseSensitive: false)          .hasMatch(normalized) ||      RegExp(r'^\d+$').hasMatch(normalized);} // ============================================================================// New neo-bank ordering UI helpers// ============================================================================ class _OrderPreviewHero extends StatelessWidget {  const _OrderPreviewHero({    required this.label,    required this.option,    required this.skin,  });   final String label;  final _OrderableCardType? option;  final NeoCardSkin skin;   @override  Widget build(BuildContext context) {    // The order screen has exactly one job before the form: show the object.    // ether.fi stops its page dead with "Your card is ready" at 80 px; we    // answer with the card itself, in the material, tiltable under a pointer    // before it is ordered. Bounded to 440 so a 1440 desktop shows a card    // rather than a billboard.    final preview = Center(      child: ConstrainedBox(        constraints: const BoxConstraints(maxWidth: 440),        child: AspectRatio(          aspectRatio: CardFace.aspectRatio,          child: CardeoTiltCard(            child: context.isCardeoTheme                ? CardFace(                    enableHoverTilt: false,                    card: _previewCard,                    status: CardStatus.pending,                    semanticsLabel: 'Preview of $label, '                        '${option?.isVirtual ?? true ? 'virtual' : 'physical'} '                        'card',                  )                : NeoBankCard(                    label: label,                    last4: '••••',                    network: '',                    virtual: option?.isVirtual ?? true,                    skin: skin,                    holderName: 'YOUR NAME',                    artworkUrl: option?.artworkUrl,                    artworkAlt: option?.artworkAlt,                    textColorHex: option?.textColorHex,                  ),          ),        ),      ),    );    // No CardeoSheenScope here on purpose: the living card owns its arrival    // pass outright and needs no scope clock, so adding one would only put a    // second host budget on a route that has exactly one moment.    return preview;  }   /// The card as it will exist, built from what the form knows so far. Zeroed  /// money because nothing has been issued: the face never prints a balance.  PaymentCard get _previewCard => PaymentCard(        id: 'preview',        label: label,        last4: '',        network: '',        currency: option?.currency ?? 'USD',        status: CardStatus.pending,        balance: Money(currency: option?.currency ?? 'USD', minorUnits: 0),        spendThisMonth:            Money(currency: option?.currency ?? 'USD', minorUnits: 0),        limit: Money(currency: option?.currency ?? 'USD', minorUnits: 0),        virtual: option?.isVirtual ?? true,        cardImageUrl: option?.artworkUrl ?? '',        cardImageAlt: option?.artworkAlt ?? '',        cardTextColor: option?.textColorHex ?? '',      );} class _SectionLabel extends StatelessWidget {  const _SectionLabel({    required this.step,    required this.title,    required this.subtitle,  });   final String step;  final String title;  final String subtitle;   @override  Widget build(BuildContext context) {    final colors = Theme.of(context).colorScheme;    return Row(      crossAxisAlignment: CrossAxisAlignment.start,      children: [        Container(          width: 28,          height: 28,          alignment: Alignment.center,          decoration: BoxDecoration(            shape: BoxShape.circle,            color: colors.primaryContainer,          ),          child: Text(            step,            style: TextStyle(              color: colors.onPrimaryContainer,              fontWeight: FontWeight.w700,            ),          ),        ),        const SizedBox(width: 10),        Expanded(          child: Column(            crossAxisAlignment: CrossAxisAlignment.start,            children: [              Text(                title,                style: Theme.of(context).textTheme.titleMedium?.copyWith(                      fontWeight: FontWeight.w700,                    ),              ),              Text(                subtitle,                style: Theme.of(context).textTheme.bodySmall?.copyWith(                      color: colors.onSurfaceVariant,                    ),              ),            ],          ),        ),      ],    );  }} class _CardTypePickerTile extends StatelessWidget {  const _CardTypePickerTile({    required this.option,    required this.selected,    required this.onTap,  });   final _OrderableCardType option;  final bool selected;  final VoidCallback onTap;   @override  Widget build(BuildContext context) {    final colors = Theme.of(context).colorScheme;    return Semantics(      label:          '${option.name}, ${option.currency}, ${option.isVirtual ? 'Virtual' : 'Physical'}',      checked: selected,      inMutuallyExclusiveGroup: true,      enabled: option.canOrder,      onTap: option.canOrder ? onTap : null,      excludeSemantics: true,      child: InkWell(        borderRadius: BorderRadius.circular(18),        onTap: option.canOrder ? onTap : null,        child: AnimatedContainer(          duration: const Duration(milliseconds: 180),          width: 200,          padding: const EdgeInsets.all(14),          decoration: BoxDecoration(            borderRadius: BorderRadius.circular(18),            color: selected                ? colors.primaryContainer.withValues(alpha: 0.6)                : colors.surfaceContainerHigh,            border: Border.all(              color: selected ? colors.primary : colors.outlineVariant,              width: selected ? 2 : 1,            ),          ),          child: Column(            crossAxisAlignment: CrossAxisAlignment.start,            children: [              Row(                children: [                  if (option.artworkUrl.isNotEmpty)                    ClipRRect(                      borderRadius: BorderRadius.circular(5),                      child: Image.network(                        webHtmlElementStrategy: WebHtmlElementStrategy.fallback,                        option.artworkUrl,                        width: 42,                        height: 27,                        fit: BoxFit.cover,                        semanticLabel: option.artworkAlt.isEmpty                            ? context.tr('Card design')                            : option.artworkAlt,                        errorBuilder: (_, __, ___) => Icon(                          option.isVirtual                              ? Icons.smartphone_rounded                              : Icons.credit_card,                          color: selected ? colors.primary : colors.onSurface,                          size: 22,                        ),                      ),                    )                  else                    Icon(                      option.isVirtual                          ? Icons.smartphone_rounded                          : Icons.credit_card,                      color: selected ? colors.primary : colors.onSurface,                      size: 22,                    ),                  const Spacer(),                  if (selected)                    Icon(Icons.check_circle, color: colors.primary, size: 20),                ],              ),              const SizedBox(height: 10),              Text(                option.name,                maxLines: 2,                overflow: TextOverflow.ellipsis,                style: Theme.of(context).textTheme.titleSmall?.copyWith(                      fontWeight: FontWeight.w700,                    ),              ),              const SizedBox(height: 4),              Text(                '${option.currency} • ${option.isVirtual ? 'Virtual' : 'Physical'}',                style: Theme.of(context).textTheme.bodySmall?.copyWith(                      color: colors.onSurfaceVariant,                    ),              ),              if (!option.canOrder) ...[                const SizedBox(height: 6),                Text(                  context.tr('Unavailable'),                  style: TextStyle(                    color: colors.error,                    fontSize: 11,                    fontWeight: FontWeight.w600,                  ),                ),              ],            ],          ),        ),      ),    );  }} class _SkinPicker extends StatelessWidget {  const _SkinPicker({required this.selectedIndex, required this.onSelect});   final int selectedIndex;  final ValueChanged<int> onSelect;   @override  Widget build(BuildContext context) {    return SizedBox(      height: 64,      child: ListView.separated(        scrollDirection: Axis.horizontal,        itemCount: NeoCardSkin.all.length,        separatorBuilder: (_, __) => const SizedBox(width: 12),        itemBuilder: (context, index) {          final skin = NeoCardSkin.all[index];          final skinColors = skin.colorsFor(context);          final selected = index == selectedIndex;          return GestureDetector(            onTap: () => onSelect(index),            child: AnimatedContainer(              duration: const Duration(milliseconds: 160),              width: 88,              decoration: BoxDecoration(                borderRadius: BorderRadius.circular(14),                gradient: LinearGradient(                  colors: skinColors,                  begin: Alignment.topLeft,                  end: Alignment.bottomRight,                ),                border: Border.all(                  color: selected                      ? Theme.of(context).colorScheme.primary                      : Colors.transparent,                  width: 3,                ),                boxShadow: [                  if (selected)                    BoxShadow(                      color: skinColors.last.withValues(alpha: 0.45),                      blurRadius: 14,                      offset: const Offset(0, 6),                    ),                ],              ),              alignment: Alignment.bottomLeft,              padding: const EdgeInsets.all(8),              child: Text(                skin.name,                style: TextStyle(                  color: context.brandDesign.color(                      Theme.of(context).brightness, 'cardForeground',                      fallback: Colors.white),                  fontWeight: FontWeight.w700,                  fontSize: 11,                  letterSpacing: 0.6,                ),              ),            ),          );        },      ),    );  }} class _SummaryRow extends StatelessWidget {  const _SummaryRow({required this.label, required this.value});  final String label;  final String value;   @override  Widget build(BuildContext context) {    final colors = Theme.of(context).colorScheme;    return Padding(      padding: const EdgeInsets.symmetric(vertical: 4),      child: Row(        crossAxisAlignment: CrossAxisAlignment.start,        children: [          SizedBox(            width: 96,            child: Text(              label,              style: Theme.of(context).textTheme.bodyMedium?.copyWith(                    color: colors.onSurfaceVariant,                  ),            ),          ),          Expanded(            child: Text(              value.isEmpty ? '—' : value,              style: Theme.of(context).textTheme.bodyMedium?.copyWith(                    fontWeight: FontWeight.w600,                  ),            ),          ),        ],      ),    );  }} /// Costs the customer commits to by ordering, resolved from the card type/// and, where the programme says so, the tier's own monthly price.class _CardCosts {  const _CardCosts(this.rows);   factory _CardCosts.from(_OrderableCardType option, PlatformResource? tier,      [CardDiscount? discount, bool applyTierOverrides = true]) {    String? tierFee(String period) => tier == null        ? null        : _textValue(tier.metadata, [            '${period}Fee',            '${period[0].toUpperCase()}${period.substring(1)}Fee',            '${period}SubscriptionFee',            '${period[0].toUpperCase()}${period.substring(1)}SubscriptionFee',            '${period}Price',            '${period[0].toUpperCase()}${period.substring(1)}Price',          ]);    String label(String? value, [String? fee]) {      if (value == null || value.trim().isEmpty) return 'Not listed';      final base = double.tryParse(value.replaceAll(',', '.'));      final amount = base == null || fee == null          ? base          : discount?.price(base, fee) ?? base;      if (amount == 0) return 'Free';      return _moneyText(          amount?.toStringAsFixed(2) ?? value, option.feeCurrency);    }     return _CardCosts([      ('Card issue fee', label(option.issueFee, 'buy')),      (        'Monthly fee',        label(            option.tierOverridesMonthly && applyTierOverrides                ? tierFee('monthly')                : option.monthlyFee,            'monthly')      ),      (        'Yearly fee',        label(            option.tierOverridesYearly && applyTierOverrides                ? tierFee('yearly')                : option.yearlyFee,            'yearly')      ),      if ((double.tryParse(option.replacementFee ?? '') ?? 0) > 0)        ('Replacement fee', label(option.replacementFee)),      if (!option.isVirtual) ...[        (          'Production',          option.customerPaysProduction ? 'Charged by the issuer' : 'Included'        ),        (          'Delivery',          option.customerPaysShipping ? 'Charged by the issuer' : 'Included'        ),      ],    ]);  }   final List<(String, String)> rows;} class _CostsPanel extends StatelessWidget {  const _CostsPanel({required this.option, required this.tier, this.discount});   final _OrderableCardType option;  final PlatformResource? tier;  final CardDiscount? discount;   @override  Widget build(BuildContext context) {    final costs = _CardCosts.from(option, tier, discount);    final original = Map.fromEntries(        _CardCosts.from(option, tier, null, discount?.isValid == true)            .rows            .map((row) => MapEntry(row.$1, row.$2)));    const fees = {      'Card issue fee': 'buy',      'Monthly fee': 'monthly',      'Yearly fee': 'yearly'    };    String? annotation(String name) {      final fee = fees[name];      if (fee == null) return null;      return discount?.description(fee) ??          ((fee == 'monthly' && option.tierOverridesMonthly) ||                  (fee == 'yearly' && option.tierOverridesYearly)              ? 'Tier override'              : null);    }     final isCardeo = context.isCardeoTheme;    final rows = Column(      crossAxisAlignment: CrossAxisAlignment.stretch,      children: [        Text(          context.tr('Costs'),          style: TextStyle(            fontSize: 13.5,            fontWeight: FontWeight.w700,            color: isCardeo ? CardeoPalette.of(context).ink : null,          ),        ),        const SizedBox(height: 6),        for (final row in costs.rows)          Padding(            padding: const EdgeInsets.symmetric(vertical: 4),            child: Row(              children: [                Text(                  row.$1,                  style: TextStyle(                    fontSize: 12.5,                    color: isCardeo                        ? CardeoPalette.of(context).textSecondary                        : Theme.of(context).colorScheme.onSurfaceVariant,                  ),                ),                const SizedBox(width: 12),                Expanded(                  child: Column(                    crossAxisAlignment: CrossAxisAlignment.end,                    children: [                      if (original[row.$1] != row.$2 &&                          original[row.$1] != 'Not listed')                        Text(original[row.$1]!,                            style: TextStyle(                              decoration: TextDecoration.lineThrough,                              color: Theme.of(context)                                  .colorScheme                                  .onSurfaceVariant,                              fontSize: 11,                            )),                      Text(                        row.$2,                        textAlign: TextAlign.end,                        style: TextStyle(                          fontSize: 13,                          fontWeight: FontWeight.w600,                          color:                              isCardeo ? CardeoPalette.of(context).ink : null,                          fontFeatures: const [FontFeature.tabularFigures()],                        ),                      ),                      if (annotation(row.$1) != null)                        Text(context.tr(annotation(row.$1)!),                            style: TextStyle(                              fontSize: 11,                              color: Theme.of(context).colorScheme.primary,                            )),                    ],                  ),                ),              ],            ),          ),        const SizedBox(height: 4),        Text(          context.tr(              'The issue fee is charged when the card is created; the monthly fee is charged from your card balance each month.'),          style: TextStyle(            fontSize: 11.5,            height: 1.4,            color: isCardeo                ? CardeoPalette.of(context).textTertiary                : Theme.of(context).colorScheme.onSurfaceVariant,          ),        ),      ],    );    if (!isCardeo) {      return NeoSurfaceCard(child: rows);    }    return CardeoGlassPanel(      radius: 16,      padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),      child: rows,    );  }}
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.