DEV3 Technologies home

Coupon code field

Promo code input with apply and remove states, async validation, inline errors and a discount line item.

FlutterFormsfluttercoupondiscounttextfield

Code

import 'package:flutter/material.dart';

class Coupon {
  const Coupon(this.code, this.percentOff);
  final String code;
  final int percentOff;
}

typedef CouponValidator = Future<Coupon> Function(String code); // throws String on rejection

enum _Phase { idle, checking, applied }

class CouponField extends StatefulWidget {
  const CouponField({super.key, required this.validate, required this.onChanged});
  final CouponValidator validate;
  final ValueChanged<Coupon?> onChanged; // null when removed

  @override
  State<CouponField> createState() => _CouponFieldState();
}

class _CouponFieldState extends State<CouponField> {
  final _text = TextEditingController();
  _Phase _phase = _Phase.idle;
  Coupon? _coupon;
  String? _error;

  @override
  void dispose() {
    _text.dispose();
    super.dispose();
  }

  Future<void> _apply() async {
    final code = _text.text.trim().toUpperCase();
    if (code.isEmpty) return;
    setState(() { _phase = _Phase.checking; _error = null; });
    try {
      final c = await widget.validate(code);
      if (!mounted) return;
      setState(() { _coupon = c; _phase = _Phase.applied; });
      widget.onChanged(c);
    } on String catch (reason) {
      if (mounted) setState(() { _phase = _Phase.idle; _error = reason; });
    } catch (_) {
      if (mounted) setState(() { _phase = _Phase.idle; _error = 'Could not check the code. Try again.'; });
    }
  }

  void _remove() {
    _text.clear();
    setState(() { _coupon = null; _phase = _Phase.idle; });
    widget.onChanged(null);
  }

  @override
  Widget build(BuildContext context) {
    if (_phase == _Phase.applied) {
      return ListTile(
        contentPadding: EdgeInsets.zero,
        leading: const Icon(Icons.sell_outlined),
        title: Text('${_coupon!.code} applied'),
        subtitle: Text('${_coupon!.percentOff}% off your order'),
        trailing: TextButton(onPressed: _remove, child: const Text('Remove')),
      );
    }
    return TextField(
      controller: _text,
      enabled: _phase != _Phase.checking,
      textCapitalization: TextCapitalization.characters,
      textInputAction: TextInputAction.done,
      onSubmitted: (_) => _apply(),
      decoration: InputDecoration(
        labelText: 'Promo code',
        errorText: _error,
        suffix: _phase == _Phase.checking
            ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
            : TextButton(onPressed: _apply, child: const Text('Apply')),
      ),
    );
  }
}

How it works

What it solves

A promo field has more states than a normal input: empty, checking with the server, accepted and rejected. Without handling each, users tap Apply twice or never learn why a code failed.

How it works

A small private enum drives the UI. While checking, the text field is disabled and the Apply button is replaced by a spinner. A successful code swaps the whole field for a compact tile showing the code, the discount and a Remove action, and reports the coupon to the parent through onChanged.

The validator contract is simple: return a Coupon or throw a String with a human reason such as "Expired". String errors become the field's errorText; any other exception produces a generic retry message. Codes are trimmed and uppercased before the call.

Adapting it

Compute the discount from percentOff in your cart model and show it as its own line. For fixed amounts, add a Money field to Coupon. Re-validate the code on the server when the order is placed; the client check is only a convenience.

Pitfalls

  • Throwing a plain String is unusual in Dart, but it keeps this API tiny. Use a custom exception class if you prefer.
  • Do not trust the displayed discount at payment time.
  • Removing the coupon must also update totals in the parent, which is why onChanged receives null.

How to use it

Copy the file to lib/cart/. No packages.

CouponField(
  validate: (code) => api.checkCoupon(code), // throws a String reason on failure
  onChanged: (coupon) => cart.applyCoupon(coupon),
)

Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.

Related snippets