currency_input_field 0.1.1 copy "currency_input_field: ^0.1.1" to clipboard
currency_input_field: ^0.1.1 copied to clipboard

A reusable Flutter widget for selecting a currency and entering a monetary amount, with validation, flexible layouts, and configurable sizing.

example/lib/main.dart

import 'package:currency_input_field/currency_input_field.dart';
import 'package:flutter/material.dart';

void main() {
  runApp(const CurrencyInputExampleApp());
}

class CurrencyInputExampleApp extends StatelessWidget {
  const CurrencyInputExampleApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Currency Input Field Example',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorSchemeSeed: const Color(0xFF1D4ED8),
        useMaterial3: true,
      ),
      home: const CurrencyInputDemoPage(),
    );
  }
}

class CurrencyInputDemoPage extends StatelessWidget {
  const CurrencyInputDemoPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Currency Input Field')),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: const [
          _IntroSection(),
          SizedBox(height: 16),
          AdminPayoutExample(),
          SizedBox(height: 16),
          DonationExample(),
          SizedBox(height: 16),
          InvoiceCollectionExample(),
          SizedBox(height: 16),
          FixedInvoiceSettlementExample(),
          SizedBox(height: 16),
          QuickTransferInlineExample(),
          SizedBox(height: 16),
          WalletTopUpExample(),
          SizedBox(height: 24),
        ],
      ),
    );
  }
}

class _IntroSection extends StatelessWidget {
  const _IntroSection();

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);

    return Card(
      clipBehavior: Clip.antiAlias,
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: DefaultTextStyle(
          style: theme.textTheme.bodyMedium!,
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text(
                'What this example app demonstrates',
                style: theme.textTheme.titleLarge,
              ),
              const SizedBox(height: 8),
              const Text(
                'These examples are based on the actual component API: '
                'generic currency types, controller support, amount and currency '
                'validation hooks, adaptive/inline/stacked layouts, read-only mode, '
                'and explicit sizing controls for compact or spacious UI.',
              ),
              const SizedBox(height: 12),
              const _FeatureBullet(
                text: 'String currencies and enum currencies',
              ),
              const _FeatureBullet(
                text: 'Compact admin-style and mobile-friendly configurations',
              ),
              const _FeatureBullet(
                text: 'Required fields, min/max checks, unsupported currencies',
              ),
              const _FeatureBullet(
                text: 'Cross-field business validation on submit',
              ),
              const _FeatureBullet(
                text: 'Prefilled controller-driven form state',
              ),
            ],
          ),
        ),
      ),
    );
  }
}

class _FeatureBullet extends StatelessWidget {
  const _FeatureBullet({required this.text});

  final String text;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.only(bottom: 6),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          const Text('•  '),
          Expanded(child: Text(text)),
        ],
      ),
    );
  }
}

class ExampleSection extends StatelessWidget {
  const ExampleSection({
    super.key,
    required this.title,
    required this.description,
    required this.whyItFits,
    required this.child,
  });

  final String title;
  final String description;
  final String whyItFits;
  final Widget child;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);

    return Card(
      clipBehavior: Clip.antiAlias,
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(title, style: theme.textTheme.titleLarge),
            const SizedBox(height: 8),
            Text(description, style: theme.textTheme.bodyMedium),
            const SizedBox(height: 8),
            Text(
              whyItFits,
              style: theme.textTheme.bodySmall?.copyWith(
                color: theme.colorScheme.onSurfaceVariant,
              ),
            ),
            const SizedBox(height: 16),
            child,
          ],
        ),
      ),
    );
  }
}

class SubmissionResult extends StatelessWidget {
  const SubmissionResult({
    super.key,
    required this.message,
    required this.isSuccess,
  });

  final String? message;
  final bool isSuccess;

  @override
  Widget build(BuildContext context) {
    if (message == null) {
      return const SizedBox.shrink();
    }

    final scheme = Theme.of(context).colorScheme;
    final background =
        isSuccess ? scheme.primaryContainer : scheme.errorContainer;
    final foreground =
        isSuccess ? scheme.onPrimaryContainer : scheme.onErrorContainer;

    return Container(
      width: double.infinity,
      margin: const EdgeInsets.only(top: 12),
      padding: const EdgeInsets.all(12),
      decoration: BoxDecoration(
        color: background,
        borderRadius: BorderRadius.circular(12),
      ),
      child: Text(message!, style: TextStyle(color: foreground)),
    );
  }
}

class AdminPayoutExample extends StatefulWidget {
  const AdminPayoutExample({super.key});

  @override
  State<AdminPayoutExample> createState() => _AdminPayoutExampleState();
}

class _AdminPayoutExampleState extends State<AdminPayoutExample> {
  final _formKey = GlobalKey<FormState>();
  final _controller = CurrencyInputController<String>(
    initialCurrency: 'USD',
    initialAmount: '250',
  );

  String? _result;
  bool _isSuccess = false;

  void _submit() {
    final isValid = _formKey.currentState?.validate() ?? false;

    setState(() {
      if (!isValid) {
        _isSuccess = false;
        _result = 'Payout request not submitted. Fix the validation errors.';
        return;
      }

      _isSuccess = true;
      _result =
          'Payout queued: ${_controller.currency} ${_controller.amountText}';
    });
  }

  @override
  Widget build(BuildContext context) {
    return ExampleSection(
      title: '1. Finance admin payout form',
      description:
          'A back-office finance user is creating a partner payout. The form '
          'needs to stay compact, dense, and fast to scan on wider screens.',
      whyItFits:
          'This uses an inline layout, tighter padding, and strict payout rules. '
          'It demonstrates a more enterprise/admin-style configuration.',
      child: Form(
        key: _formKey,
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            CurrencyInputField<String>(
              controller: _controller,
              currencies: const ['USD', 'GBP', 'ZWG', 'ZAR'],
              currencyLabelBuilder: (currency) => currency,
              currencyHintText: 'Settlement currency',
              monetaryHintText: 'Payout amount',
              layoutMode: CurrencyInputLayoutMode.inline,
              requireCurrency: true,
              requireAmount: true,
              useLabelText: true,
              containerPadding: const EdgeInsets.symmetric(
                horizontal: 4,
                vertical: 0,
              ),
              fieldHorizontalPadding: 10,
              fieldVerticalPadding: 8,
              inlineDividerHeight: 34,
              stackedDividerSpacing: 1,
              currencyFlex: 4,
              amountFlex: 6,
              amountValidator: (amountText) {
                final amount = double.tryParse(amountText);
                if (amount == null) {
                  return 'Enter a valid payout amount';
                }
                if (amount <= 0) {
                  return 'Amount must be greater than zero';
                }
                if (amount > 10000) {
                  return 'Admin payouts are capped at 10,000';
                }
                return null;
              },
              currencyValidator: (currency) {
                if (currency == 'ZAR') {
                  return 'ZAR payouts are handled in a separate flow';
                }
                return null;
              },
            ),
            const SizedBox(height: 12),
            FilledButton.icon(
              onPressed: _submit,
              icon: const Icon(Icons.send),
              label: const Text('Submit payout'),
            ),
            SubmissionResult(message: _result, isSuccess: _isSuccess),
          ],
        ),
      ),
    );
  }
}

class DonationExample extends StatefulWidget {
  const DonationExample({super.key});

  @override
  State<DonationExample> createState() => _DonationExampleState();
}

class _DonationExampleState extends State<DonationExample> {
  final _formKey = GlobalKey<FormState>();
  final _controller = CurrencyInputController<String>();

  String? _result;
  bool _isSuccess = false;

  void _submit() {
    final isValid = _formKey.currentState?.validate() ?? false;

    setState(() {
      if (!isValid) {
        _isSuccess = false;
        _result = 'Donation not submitted. Review the highlighted fields.';
        return;
      }

      _isSuccess = true;
      _result =
          'Donation ready: ${_controller.currency} ${_controller.amountText}';
    });
  }

  @override
  Widget build(BuildContext context) {
    return ExampleSection(
      title: '2. Mobile donation flow',
      description:
          'A consumer donation screen needs large touch targets and very clear '
          'labels for first-time users on mobile devices.',
      whyItFits:
          'This uses stacked mode, larger vertical padding, and friendly hints. '
          'It feels more consumer/mobile-oriented than the admin example.',
      child: Form(
        key: _formKey,
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            CurrencyInputField<String>(
              controller: _controller,
              currencies: const ['USD', 'GBP', 'ZAR'],
              currencyLabelBuilder: (currency) => currency,
              currencyHintText: 'Choose donation currency',
              monetaryHintText: 'Enter donation amount',
              layoutMode: CurrencyInputLayoutMode.stacked,
              requireCurrency: true,
              requireAmount: true,
              useLabelText: true,
              containerPadding: const EdgeInsets.symmetric(
                horizontal: 8,
                vertical: 4,
              ),
              fieldHorizontalPadding: 14,
              fieldVerticalPadding: 14,
              inlineDividerHeight: 40,
              stackedDividerSpacing: 4,
              amountValidator: (amountText) {
                final amount = double.tryParse(amountText);
                if (amount == null) {
                  return 'Enter a valid donation amount';
                }
                if (amount < 1) {
                  return 'Minimum donation is 1';
                }
                if (amount > 5000) {
                  return 'For larger donations, contact our team';
                }
                return null;
              },
            ),
            const SizedBox(height: 12),
            FilledButton(onPressed: _submit, child: const Text('Donate now')),
            SubmissionResult(message: _result, isSuccess: _isSuccess),
          ],
        ),
      ),
    );
  }
}

enum InvoiceCurrency { usd, gbp, eur }

extension InvoiceCurrencyX on InvoiceCurrency {
  String get code {
    switch (this) {
      case InvoiceCurrency.usd:
        return 'USD';
      case InvoiceCurrency.gbp:
        return 'GBP';
      case InvoiceCurrency.eur:
        return 'EUR';
    }
  }
}

class InvoiceCollectionExample extends StatefulWidget {
  const InvoiceCollectionExample({super.key});

  @override
  State<InvoiceCollectionExample> createState() =>
      _InvoiceCollectionExampleState();
}

class _InvoiceCollectionExampleState extends State<InvoiceCollectionExample> {
  final _formKey = GlobalKey<FormState>();
  final _controller = CurrencyInputController<InvoiceCurrency>(
    initialCurrency: InvoiceCurrency.usd,
  );

  String? _result;
  bool _isSuccess = false;

  void _submit() {
    final isValid = _formKey.currentState?.validate() ?? false;

    setState(() {
      if (!isValid) {
        _isSuccess = false;
        _result = 'Invoice payment request is invalid.';
        return;
      }

      _isSuccess = true;
      _result =
          'Invoice payment captured: ${_controller.currency?.code} ${_controller.amountText}';
    });
  }

  @override
  Widget build(BuildContext context) {
    return ExampleSection(
      title: '3. Cross-border invoice payment',
      description:
          'A SaaS billing team supports multiple settlement currencies and uses '
          'enum-backed values to keep the payment flow type-safe.',
      whyItFits:
          'This example shows the generic API in action with enum currencies, '
          'adaptive layout, and currency-specific business rules.',
      child: Form(
        key: _formKey,
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            CurrencyInputField<InvoiceCurrency>(
              controller: _controller,
              currencies: InvoiceCurrency.values,
              currencyLabelBuilder: (currency) => currency.code,
              currencyHintText: 'Invoice currency',
              monetaryHintText: 'Invoice amount',
              layoutMode: CurrencyInputLayoutMode.adaptive,
              stackBreakpoint: 420,
              requireCurrency: true,
              requireAmount: true,
              useLabelText: false,
              containerPadding: const EdgeInsets.symmetric(
                horizontal: 6,
                vertical: 2,
              ),
              fieldHorizontalPadding: 12,
              fieldVerticalPadding: 10,
              inlineDividerHeight: 36,
              stackedDividerSpacing: 2,
              amountValidator: (amountText) {
                final amount = double.tryParse(amountText);
                if (amount == null) {
                  return 'Enter a valid invoice amount';
                }
                if (amount <= 0) {
                  return 'Amount must be greater than zero';
                }
                return null;
              },
              validator: (value) {
                final amount = value.amount ?? 0;
                switch (value.currency) {
                  case InvoiceCurrency.usd:
                    if (amount < 10) {
                      return 'Minimum USD invoice payment is 10';
                    }
                    break;
                  case InvoiceCurrency.gbp:
                    if (amount < 8) {
                      return 'Minimum GBP invoice payment is 8';
                    }
                    break;
                  case InvoiceCurrency.eur:
                    if (amount < 9) {
                      return 'Minimum EUR invoice payment is 9';
                    }
                    break;
                  case null:
                    break;
                }
                return null;
              },
            ),
            const SizedBox(height: 12),
            FilledButton.tonal(
              onPressed: _submit,
              child: const Text('Pay invoice'),
            ),
            SubmissionResult(message: _result, isSuccess: _isSuccess),
          ],
        ),
      ),
    );
  }
}

class FixedInvoiceSettlementExample extends StatefulWidget {
  const FixedInvoiceSettlementExample({super.key});

  @override
  State<FixedInvoiceSettlementExample> createState() =>
      _FixedInvoiceSettlementExampleState();
}

class _FixedInvoiceSettlementExampleState
    extends State<FixedInvoiceSettlementExample> {
  final _formKey = GlobalKey<FormState>();
  final _controller = CurrencyInputController<String>(
    initialCurrency: 'USD',
    initialAmount: '149.99',
  );

  String? _result;
  bool _isSuccess = false;

  void _submit() {
    final isValid = _formKey.currentState?.validate() ?? false;

    setState(() {
      if (!isValid) {
        _isSuccess = false;
        _result = 'Settlement could not continue.';
        return;
      }

      _isSuccess = true;
      _result =
          'Invoice 10482 will be settled in ${_controller.currency} for ${_controller.amountText}.';
    });
  }

  @override
  Widget build(BuildContext context) {
    return ExampleSection(
      title: '4. Fixed invoice settlement',
      description:
          'An invoice amount is already known from the backend, so the user can '
          'only choose settlement currency before confirming payment.',
      whyItFits:
          'This shows controller-prefilled state plus a read-only amount field. '
          'It is useful for review-and-confirm flows.',
      child: Form(
        key: _formKey,
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            CurrencyInputField<String>(
              controller: _controller,
              currencies: const ['USD', 'EUR'],
              currencyLabelBuilder: (currency) => currency,
              currencyHintText: 'Settle in',
              monetaryHintText: 'Invoice total',
              layoutMode: CurrencyInputLayoutMode.inline,
              readOnlyAmount: true,
              requireCurrency: true,
              requireAmount: false,
              useLabelText: true,
              containerPadding: const EdgeInsets.symmetric(
                horizontal: 4,
                vertical: 0,
              ),
              fieldHorizontalPadding: 10,
              fieldVerticalPadding: 8,
              inlineDividerHeight: 34,
              stackedDividerSpacing: 1,
              amountTextInputAction: TextInputAction.done,
              currencyValidator: (currency) {
                if (currency == 'EUR') {
                  return 'EUR settlement is not enabled for this invoice';
                }
                return null;
              },
            ),
            const SizedBox(height: 12),
            FilledButton(
              onPressed: _submit,
              child: const Text('Confirm settlement'),
            ),
            SubmissionResult(message: _result, isSuccess: _isSuccess),
          ],
        ),
      ),
    );
  }
}

class QuickTransferInlineExample extends StatefulWidget {
  const QuickTransferInlineExample({super.key});

  @override
  State<QuickTransferInlineExample> createState() =>
      _QuickTransferInlineExampleState();
}

class _QuickTransferInlineExampleState
    extends State<QuickTransferInlineExample> {
  final _formKey = GlobalKey<FormState>();
  final _controller = CurrencyInputController<String>(initialCurrency: 'USD');

  String? _result;
  bool _isSuccess = false;

  void _submit() {
    final isValid = _formKey.currentState?.validate() ?? false;

    setState(() {
      if (!isValid) {
        _isSuccess = false;
        _result = 'Transfer could not be submitted. Please fix the errors.';
        return;
      }

      _isSuccess = true;
      _result =
          'Quick transfer created: ${_controller.currency} ${_controller.amountText}';
    });
  }

  @override
  Widget build(BuildContext context) {
    return ExampleSection(
      title: '5. Quick transfer inline with icons',
      description:
          'A compact transfer form for dashboards and wallet apps where speed matters and the form needs to fit neatly into a tighter horizontal layout.',
      whyItFits:
          'This configuration uses inline layout, compact spacing, and icon-based input decoration to create a fast-entry form for desktop and admin-style screens.',
      child: Form(
        key: _formKey,
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            CurrencyInputField<String>(
              controller: _controller,
              currencies: const ['USD', 'GBP', 'EUR'],
              currencyLabelBuilder: (currency) => currency,
              currencyHintText: 'Currency',
              monetaryHintText: 'Transfer amount',
              layoutMode: CurrencyInputLayoutMode.inline,
              requireCurrency: true,
              requireAmount: true,
              useLabelText: true,
              containerPadding: const EdgeInsets.symmetric(
                horizontal: 4,
                vertical: 0,
              ),
              fieldHorizontalPadding: 10,
              fieldVerticalPadding: 8,
              inlineDividerHeight: 34,
              stackedDividerSpacing: 1,
              currencyFlex: 4,
              amountFlex: 6,
              style: const CurrencyInputFieldStyle(
                currencyDecoration: InputDecoration(
                  labelText: 'Currency',
                  hintText: 'Choose currency',
                  prefixIcon: Icon(Icons.currency_exchange),
                ),
                amountDecoration: InputDecoration(
                  labelText: 'Transfer amount',
                  hintText: 'Enter amount',
                  prefixIcon: Icon(Icons.send_outlined),
                  suffixIcon: Icon(Icons.arrow_forward),
                ),
              ),
              amountValidator: (value) {
                final amount = double.tryParse(value);
                if (amount == null) {
                  return 'Enter a valid transfer amount';
                }
                if (amount <= 0) {
                  return 'Amount must be greater than zero';
                }
                if (amount > 5000) {
                  return 'Quick transfers are capped at 5000';
                }
                return null;
              },
              currencyValidator: (currency) {
                if (currency == 'EUR') {
                  return 'EUR quick transfers are not available';
                }
                return null;
              },
            ),
            const SizedBox(height: 12),
            FilledButton.icon(
              onPressed: _submit,
              icon: const Icon(Icons.bolt),
              label: const Text('Send transfer'),
            ),
            SubmissionResult(message: _result, isSuccess: _isSuccess),
          ],
        ),
      ),
    );
  }
}

class WalletTopUpExample extends StatefulWidget {
  const WalletTopUpExample({super.key});

  @override
  State<WalletTopUpExample> createState() => _WalletTopUpExampleState();
}

class _WalletTopUpExampleState extends State<WalletTopUpExample> {
  final _formKey = GlobalKey<FormState>();
  final _controller = CurrencyInputController<String>(initialCurrency: 'USD');

  String? _result;
  bool _isSuccess = false;

  void _submit() {
    final isValid = _formKey.currentState?.validate() ?? false;

    setState(() {
      if (!isValid) {
        _isSuccess = false;
        _result = 'Top-up could not be submitted. Please fix the errors.';
        return;
      }

      _isSuccess = true;
      _result =
          'Wallet top-up created: ${_controller.currency} ${_controller.amountText}';
    });
  }

  @override
  Widget build(BuildContext context) {
    return ExampleSection(
      title: '6. Wallet top-up with icons',
      description:
          'A consumer wallet top-up flow that uses icons to make the form feel more guided and mobile-friendly.',
      whyItFits:
          'This configuration uses stacked layout, decorated inputs, and icon-led affordances to show how teams can build a more visual payment experience using the current API.',
      child: Form(
        key: _formKey,
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            CurrencyInputField<String>(
              controller: _controller,
              currencies: const ['USD', 'GBP', 'ZWG'],
              currencyLabelBuilder: (currency) => currency,
              currencyHintText: 'Top-up currency',
              monetaryHintText: 'Top-up amount',
              layoutMode: CurrencyInputLayoutMode.stacked,
              requireCurrency: true,
              requireAmount: true,
              useLabelText: true,
              containerPadding: const EdgeInsets.symmetric(
                horizontal: 8,
                vertical: 4,
              ),
              fieldHorizontalPadding: 14,
              fieldVerticalPadding: 12,
              inlineDividerHeight: 40,
              stackedDividerSpacing: 4,
              style: const CurrencyInputFieldStyle(
                currencyDecoration: InputDecoration(
                  labelText: 'Top-up currency',
                  hintText: 'Choose currency',
                  prefixIcon: Icon(Icons.account_balance_wallet_outlined),
                ),
                amountDecoration: InputDecoration(
                  labelText: 'Top-up amount',
                  hintText: 'Enter amount',
                  prefixIcon: Icon(Icons.payments_outlined),
                  suffixIcon: Icon(Icons.edit_outlined),
                ),
              ),
              amountValidator: (value) {
                final amount = double.tryParse(value);
                if (amount == null) {
                  return 'Enter a valid top-up amount';
                }
                if (amount < 5) {
                  return 'Minimum top-up is 5';
                }
                if (amount > 2000) {
                  return 'Maximum top-up is 2000';
                }
                return null;
              },
              currencyValidator: (currency) {
                if (currency == 'ZWG') {
                  return 'ZWG top-ups are currently unavailable';
                }
                return null;
              },
            ),
            const SizedBox(height: 12),
            FilledButton.icon(
              onPressed: _submit,
              icon: const Icon(Icons.arrow_upward),
              label: const Text('Top up wallet'),
            ),
            SubmissionResult(message: _result, isSuccess: _isSuccess),
          ],
        ),
      ),
    );
  }
}
2
likes
160
points
85
downloads

Documentation

API reference

Publisher

verified publishermunyaradzichigangawa.co.zw

Weekly Downloads

A reusable Flutter widget for selecting a currency and entering a monetary amount, with validation, flexible layouts, and configurable sizing.

Homepage
Repository (GitHub)
View/report issues

License

MIT (license)

Dependencies

flutter

More

Packages that depend on currency_input_field