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
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user