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
184 lines
5.9 KiB
Dart
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),
|
|
),
|
|
);
|
|
}
|
|
}
|