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

card_auto_top_up_sheet.dart

mobile_flutter/lib/features/cards/presentation/card_auto_top_up_sheet.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–384

import 'dart:async'; import 'package:flutter/material.dart';import 'package:flutter_riverpod/flutter_riverpod.dart';import '../../../core/l10n/app_localizations.dart';import '../../../core/models/banking_models.dart';import '../../../core/widgets/app_states.dart';import '../../profile/presentation/security_sheets.dart' show showCardeoSheet;import '../data/card_auto_top_up_api.dart'; Future<void> showCardAutoTopUp(BuildContext context, PaymentCard card) =>    showCardeoSheet<void>(context,        builder: (_) => CardAutoTopUpSheet(card: card)); class CardAutoTopUpSheet extends ConsumerStatefulWidget {  const CardAutoTopUpSheet({required this.card, super.key});  final PaymentCard card;  @override  ConsumerState<CardAutoTopUpSheet> createState() => _CardAutoTopUpSheetState();} class _CardAutoTopUpSheetState extends ConsumerState<CardAutoTopUpSheet> {  final _form = GlobalKey<FormState>();  final _threshold = TextEditingController();  final _target = TextEditingController();  final _limit = TextEditingController();  final _maximum = TextEditingController();  final _depositAmounts = {    for (final currency in ['USDC', 'USDT']) currency: TextEditingController()  };  final _deposits = {'USDC': false, 'USDT': false};  Map<String, dynamic>? _settings;  String _mode = 'low-balance';  bool _enabled = false, _consent = false, _busy = false;  String? _error;  OverlayEntry? _savedToast;  Timer? _savedToastTimer;  bool get _enabling =>      _mode == 'deposit' ? _deposits.values.any((v) => v) : _enabled;  @override  void initState() {    super.initState();    _load();  }   @override  void dispose() {    _hideSavedToast();    for (final c in [      _threshold,      _target,      _limit,      _maximum,      ..._depositAmounts.values    ]) {      c.dispose();    }    super.dispose();  }   String _amount(Object? value) {    final amount = num.tryParse(value?.toString() ?? '') ?? 0;    return amount > 0 ? amount.toString() : '';  }   List<Map> _rows(String key) =>      (_settings?[key] as List? ?? []).whereType<Map>().toList();  void _select(String mode) {    _mode = mode;    _consent = false;    _error = null;    _hideSavedToast();    final s = _settings!;    _enabled =        s[mode == 'low-balance' ? 'lowBalanceEnabled' : 'failedTxEnabled'] ==            true;    _threshold.text = _amount(s['watermark']);    _target.text = _amount(s['targetBalance']);    _maximum.text = _amount(s['failedTxMaxAmount']);    _limit.text = _amount(s[mode == 'low-balance'        ? 'lowBalanceMonthlyLimit'        : 'failedTxMonthlyLimit']);    for (final currency in _deposits.keys) {      final existing = _rows('depositSettings')          .where((s) => s['currency'] == currency)          .firstOrNull;      _deposits[currency] = existing?['enabled'] == true;      _depositAmounts[currency]!.text = _amount(existing?['maxAmount']);    }  }   Future<void> _load() async {    setState(() {      _busy = true;      _error = null;    });    try {      final settings =          await ref.read(cardAutoTopUpApiProvider).load(widget.card.id);      if (!mounted) return;      setState(() {        _settings = settings;        _select(settings['depositEnabled'] == true            ? 'deposit'            : settings['failedTxEnabled'] == true                ? 'failed-tx'                : 'low-balance');      });    } catch (error) {      if (mounted) setState(() => _error = friendlyErrorMessage(error));    } finally {      if (mounted) setState(() => _busy = false);    }  }   double _value(TextEditingController c) =>      double.tryParse(c.text.trim().replaceAll(',', '.')) ?? 0;  Future<void> _save() async {    if (_busy || !_form.currentState!.validate()) return;    if (_enabling && !_consent) {      setState(() =>          _error = 'Accept the automatic top-up authorization to continue.');      return;    }    if (_mode == 'low-balance' &&        _enabled &&        _value(_target) <= _value(_threshold)) {      setState(() => _error =          'Target balance must be greater than the balance threshold.');      return;    }    final cardId = int.tryParse(widget.card.id);    if (cardId == null || cardId <= 0) {      setState(() => _error = 'Automatic top-up is unavailable for this card.');      return;    }    final payload = <String, dynamic>{      'cardId': cardId,      'disclaimerAccepted': _enabling && _consent    };    if (_mode == 'deposit') {      payload['settings'] = [        for (final c in _deposits.keys)          {            'currency': c,            'enabled': _deposits[c],            'maxAmount': _deposits[c]! ? _value(_depositAmounts[c]!) : 0          }      ];    } else {      payload.addAll(          {'enabled': _enabled, 'monthlyLimit': _enabled ? _value(_limit) : 0});      if (_mode == 'low-balance') {        payload.addAll({          'watermark': _enabled ? _value(_threshold) : 0,          'targetBalance': _enabled ? _value(_target) : 0        });      } else {        payload['maxAmount'] = _enabled ? _value(_maximum) : 0;      }    }    setState(() {      _busy = true;      _error = null;      _hideSavedToast();    });    try {      final settings =          await ref.read(cardAutoTopUpApiProvider).save(_mode, payload);      if (!mounted) return;      setState(() {        _settings = settings;        _select(_mode);      });      _showSavedToast();    } catch (error) {      if (mounted) setState(() => _error = friendlyErrorMessage(error));    } finally {      if (mounted) setState(() => _busy = false);    }  }   void _hideSavedToast() {    _savedToastTimer?.cancel();    _savedToastTimer = null;    _savedToast?.remove();    _savedToast?.dispose();    _savedToast = null;  }   void _showSavedToast() {    _hideSavedToast();    final colors = Theme.of(context).colorScheme;    final message = context.tr('Automatic top-up settings saved.');    // The page's ScaffoldMessenger is behind the modal sheet. Put feedback    // in the root overlay so it stays visible while the settings remain open.    _savedToast = OverlayEntry(        builder: (context) => Positioned(              top: MediaQuery.paddingOf(context).top + 16,              left: 16,              right: 16,              child: IgnorePointer(                child: Align(                  alignment: Alignment.topCenter,                  child: Semantics(                    liveRegion: true,                    child: Material(                      color: colors.inverseSurface,                      elevation: 6,                      borderRadius: BorderRadius.circular(12),                      child: Padding(                        padding: const EdgeInsets.symmetric(                            horizontal: 16, vertical: 12),                        child: Text(message,                            textAlign: TextAlign.center,                            style: TextStyle(color: colors.onInverseSurface)),                      ),                    ),                  ),                ),              ),            ));    Overlay.of(context, rootOverlay: true).insert(_savedToast!);    _savedToastTimer = Timer(const Duration(seconds: 3), _hideSavedToast);  }   Widget _number(String label, TextEditingController controller,          {bool active = true}) =>      Padding(          padding: const EdgeInsets.only(bottom: 12),          child: TextFormField(            controller: controller,            enabled: !_busy && active,            keyboardType: const TextInputType.numberWithOptions(decimal: true),            decoration: InputDecoration(labelText: context.tr(label)),            validator: (_) => active &&                    (_value(controller) <= 0 || !_value(controller).isFinite)                ? context.tr('Enter an amount greater than zero.')                : null,          ));  String _money(Object? value) => Money.formatAmount(      'USD', num.tryParse(value?.toString() ?? '')?.toDouble() ?? 0);  @override  Widget build(BuildContext context) {    final settings = _settings;    final active = [      if (settings?['lowBalanceEnabled'] == true) 'Low balance',      if (settings?['failedTxEnabled'] == true) 'Failed payment',      if (settings?['depositEnabled'] == true) 'Crypto deposit'    ];    return Form(        key: _form,        child: Column(            mainAxisSize: MainAxisSize.min,            crossAxisAlignment: CrossAxisAlignment.stretch,            children: [              Row(children: [                Expanded(                    child: Text(context.tr('Auto-Reload'),                        style: Theme.of(context).textTheme.headlineSmall)),                IconButton(                    tooltip: context.tr('Close'),                    onPressed: _busy ? null : () => Navigator.pop(context),                    icon: const Icon(Icons.close))              ]),              Text('${widget.card.displayLabel} · ${widget.card.last4}'),              const SizedBox(height: 12),              if (_busy) const LinearProgressIndicator(),              if (_error != null)                Padding(                    padding: const EdgeInsets.symmetric(vertical: 12),                    child: Text(context.tr(_error!),                        style: TextStyle(                            color: Theme.of(context).colorScheme.error))),              if (settings == null && !_busy)                TextButton(                    onPressed: _load, child: Text(context.tr('Try again'))),              if (settings != null) ...[                Text(active.isEmpty                    ? context.tr('Status: Off')                    : '${context.tr('Active')}: ${active.map(context.tr).join(', ')}'),                const SizedBox(height: 16),                DropdownButtonFormField<String>(                    initialValue: _mode,                    isExpanded: true,                    decoration:                        InputDecoration(labelText: context.tr('Trigger')),                    items: [                      for (final item in const {                        'low-balance': 'Low balance',                        'failed-tx': 'Failed payment',                        'deposit': 'Crypto deposit'                      }.entries)                        DropdownMenuItem(                            value: item.key,                            child: Text(context.tr(item.value)))                    ],                    onChanged: _busy                        ? null                        : (value) => setState(() => _select(value!))),                const SizedBox(height: 12),                Text(context.tr(                    'Uses your existing in-app balance. Applicable card top-up fees apply. Top-ups can fail if funds are insufficient.')),                const SizedBox(height: 8),                if (_mode != 'deposit') ...[                  SwitchListTile(                      contentPadding: EdgeInsets.zero,                      title: Text(context.tr('Enable this trigger')),                      value: _enabled,                      onChanged: _busy                          ? null                          : (value) => setState(() {                                _enabled = value;                                _consent = false;                              })),                  if (_mode == 'low-balance') ...[                    _number('When card balance is below (USD)', _threshold,                        active: _enabled),                    _number('Reload up to this balance (USD)', _target,                        active: _enabled),                  ] else ...[                    Text(context.tr(                        'Adds funds after an insufficient-funds decline. You may need to retry the payment.')),                    const SizedBox(height: 12),                    _number('Maximum per top-up (USD)', _maximum,                        active: _enabled),                  ],                  _number('Monthly top-up limit (USD)', _limit,                      active: _enabled),                  Text(                      '${context.tr('Used this month (UTC)')}: ${_money(settings[_mode == 'low-balance' ? 'lowBalanceUsedThisMonth' : 'failedTxUsedThisMonth'])}'),                ] else ...[                  Text(context.tr(                      'Crypto deposit triggers are available for Interlace cards. Each currency can fund one card at a time.')),                  for (final currency in _deposits.keys) ...[                    SwitchListTile(                        contentPadding: EdgeInsets.zero,                        title: Text(currency),                        value: _deposits[currency]!,                        onChanged: _busy                            ? null                            : (value) => setState(() {                                  _deposits[currency] = value;                                  _consent = false;                                })),                    for (final assigned in _rows('assignedDepositCurrencies')                        .where((a) =>                            a['currency'] == currency &&                            a['cardId'].toString() != widget.card.id))                      Text(context.tr(                          'Assigned to card {p0}. Disable it on that card before switching.',                          {                            'p0': assigned['maskedCardNumber'] ??                                assigned['cardId']                          })),                    _number('Maximum per deposit ($currency)',                        _depositAmounts[currency]!,                        active: _deposits[currency]!),                    Text(                        '${context.tr('Used this month (UTC)')}: ${Money.formatAmount(currency, num.tryParse(_rows('depositSettings').where((row) => row['currency'] == currency).firstOrNull?['usedThisMonth']?.toString() ?? '')?.toDouble() ?? 0)}'),                  ],                ],                const SizedBox(height: 12),                if (_enabling) ...[                  Text(context.tr(                      'Enabling this trigger turns off the other Auto-Reload triggers on this card.')),                  CheckboxListTile(                      contentPadding: EdgeInsets.zero,                      controlAffinity: ListTileControlAffinity.leading,                      value: _consent,                      onChanged: _busy                          ? null                          : (value) =>                              setState(() => _consent = value ?? false),                      title: Text(context.tr(                          'I authorize automatic top-ups from my in-app balance under these limits, including applicable fees.'))),                ],                FilledButton(                    onPressed: _busy ? null : _save,                    child: Text(context.tr('Save settings'))),              ],            ]));  }}
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.