import "package:flutter/services.dart"; /// What a mask slot will accept. /// /// Anything in a mask that isnt one of these is a literal, inserted for you as /// you type past it and never something you have to enter yourself. enum MaskSlot { /// `#` — 0-9. digit("#"), /// `A` — a letter, either case. letter("A"), /// `*` — a letter or a digit. alphanumeric("*"); const MaskSlot(this.token); final String token; bool accepts(String ch) => switch (this) { MaskSlot.digit => _isDigit(ch), MaskSlot.letter => _isLetter(ch), MaskSlot.alphanumeric => _isDigit(ch) || _isLetter(ch), }; static MaskSlot? of(String ch) { for (final s in MaskSlot.values) { if (s.token == ch) return s; } return null; } } bool _isDigit(String ch) { final c = ch.codeUnitAt(0); return c >= 0x30 && c <= 0x39; } bool _isLetter(String ch) { final c = ch.codeUnitAt(0); return (c >= 0x41 && c <= 0x5A) || (c >= 0x61 && c <= 0x7A); } /// Types a value into a fixed shape as you go — `### ###-####`, `AA# #AA`. /// /// `#` takes a digit, `A` a letter, `*` either; everything else is a literal /// that appears on its own once you've typed up to it. Input that doesnt fit a /// slot is dropped rather than rejected wholesale, so a pasted /// `(555) 123-4567` lands in `### ###-####` as `555 123-4567` instead of /// nothing. /// /// It works by reducing whatever the field now holds back to its *slot* /// characters and re-laying the mask over them. That means one code path for /// typing, pasting, deleting and dragging a selection, rather than four that /// each have to agree — the failure mode of hand-rolled masks is usually that /// they only got typing right. /// /// The caveat that comes with any mask: the field's value is the FORMATTED /// string. If what you store is the digits, strip it on the way out (see /// [unmask]) rather than assuming the two are the same. class MaskedTextInputFormatter extends TextInputFormatter { MaskedTextInputFormatter(this.mask) : assert(mask.length > 0, "an empty mask accepts nothing"), _slots = [for (final ch in mask.split("")) MaskSlot.of(ch)] { assert( _slots.any((s) => s != null), "a mask with no #, A or * is all literal - nothing could be typed", ); } /// e.g. `### ###-####`. final String mask; /// One entry per mask character: the slot it accepts, or null for a literal. final List _slots; /// How many characters this mask can hold once full. int get slotCount => _slots.where((s) => s != null).length; /// The slot characters of [text], with the mask's own literals taken out. /// /// The single reduction the whole formatter runs on: typing, pasting, /// deleting and dragging a selection all come through here, so they cant /// disagree with each other. /// /// It walks the mask and the text together rather than filtering the text on /// its own, which is what makes a literal that LOOKS like data behave - the /// leading `1` of `1-###-####` is consumed as the literal it is instead of /// being returned as the first digit somebody typed. Text that doesnt line /// up (a pasted `(555) 123-4567`) just has the junk skipped. String unmask(String text) { final out = StringBuffer(); var i = 0; // into the mask var t = 0; // into the text while (i < _slots.length && t < text.length) { final slot = _slots[i]; if (slot == null) { // only step over the text's copy of this literal if its actually there if (text[t] == mask[i]) t++; i++; continue; } if (slot.accepts(text[t])) { out.write(text[t]); i++; } t++; } return out.toString(); } /// Lay the mask over [raw], stopping when either runs out. /// /// Trailing literals are left off: a half typed `555` in `### ###-####` is /// `555`, not `555 ` with a space you didnt ask for and cant delete. String apply(String raw) { final out = StringBuffer(); var r = 0; for (var i = 0; i < _slots.length && r < raw.length; i++) { if (_slots[i] == null) { out.write(mask[i]); continue; } out.write(raw[r]); r++; } return out.toString(); } @override TextEditingValue formatEditUpdate( TextEditingValue oldValue, TextEditingValue newValue, ) { // reduce to slot characters, ignoring where the literals were - this is // what makes paste and drag-select behave the same as typing. var raw = unmask(newValue.text); // Deleting a literal has to delete something, or backspace looks frozen: // "555 1" backspace kills the space, the slot characters are unchanged, // and re-applying puts the space straight back. So when a delete didnt // change the slot characters, take the one before the caret too. final deleted = newValue.text.length < oldValue.text.length; if (deleted && raw == unmask(oldValue.text) && raw.isNotEmpty) { final upTo = unmask( newValue.text.substring( 0, newValue.selection.baseOffset.clamp(0, newValue.text.length), ), ).length; final cut = upTo > 0 ? upTo - 1 : 0; raw = raw.substring(0, cut) + raw.substring(cut + 1); } final formatted = apply(raw); // put the caret after the same number of slot characters it was after // before, which is the only position that survives literals moving. final before = unmask( newValue.text.substring( 0, newValue.selection.baseOffset.clamp(0, newValue.text.length), ), ).length; var offset = formatted.length; var seen = 0; for (var i = 0; i < formatted.length; i++) { if (seen == before) { offset = i; break; } if (_slots[i] != null) seen++; } return TextEditingValue( text: formatted, selection: TextSelection.collapsed( offset: offset.clamp(0, formatted.length), ), ); } }