Files
Garage-SDKs/garage_ui/lib/input_mask.dart
T
ImBenjiandClaude Opus 5.5 b269201919 The Garage SDKs, in the open
garage_auth, garage_entitlements, garage_iap and garage_ui, moved out of
Garage-Services and Metro-Map-Maker into one public repo. MIT, one readme,
docs under docs/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013F4NWNvYcdeSgqbWMT1VQ7
2026-09-23 18:49:21 +01:00

184 lines
5.9 KiB
Dart

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<MaskSlot?> _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),
),
);
}
}