diff --git a/README.md b/README.md index 89b4f06..f186a66 100644 --- a/README.md +++ b/README.md @@ -68,11 +68,24 @@ final cnpjField = TextFormField( | PIS/PASEP (NIS/NIT) | PisPasepInputFormatter() | 120.12345.67-2 | | Placa | PlacaVeiculoInputFormatter() | AAA-1234 | | Real | RealInputFormatter() | 20.550 | -| Telefone | TelefoneInputFormatter() | (99) 9999-9999 | +| Telefone fixo | TelefoneFixoInputFormatter() | (99) 9999-9999 | +| Celular | CelularInputFormatter() | (99) 99999-9999 | +| Telefone ou celular | TelefoneOuCelularInputFormatter() | Máscara conforme a quantidade de dígitos | | Validade cartão | ValidadeCartaoInputFormatter() | 12/24 ou 12/2024 | | Temperatura | TemperaturaInputFormatter() | 27,1 | -Os formatadores aplicam máscaras; não validam os documentos. Para verificar CPF, CNPJ, NUP ou PIS/PASEP, use os validadores em `UtilBrasilFields`. +Os formatadores aplicam máscaras; não substituem validadores. Para verificar CPF, CNPJ, NUP ou PIS/PASEP, use os validadores em `UtilBrasilFields`. + +Para telefones, escolha `TelefoneFixoInputFormatter()` quando o campo aceitar somente números fixos e `CelularInputFormatter()` quando aceitar somente celulares. `TelefoneOuCelularInputFormatter()` escolhe a máscara conforme o tamanho da entrada. `TelefoneInputFormatter()` continua disponível com o comportamento automático legado e exige que celulares comecem por `9` após o DDD. + +```dart +final celularField = TextFormField( + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + CelularInputFormatter(), + ], +); +``` ### Modelos diff --git a/example/lib/main.dart b/example/lib/main.dart index 3fd4234..62f728f 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -906,6 +906,13 @@ class _FormatterDemo { } if (value is RealInputFormatter) return 'RealInputFormatter'; if (value is TelefoneInputFormatter) return 'TelefoneInputFormatter'; + if (value is TelefoneFixoInputFormatter) { + return 'TelefoneFixoInputFormatter'; + } + if (value is CelularInputFormatter) return 'CelularInputFormatter'; + if (value is TelefoneOuCelularInputFormatter) { + return 'TelefoneOuCelularInputFormatter'; + } if (value is TemperaturaInputFormatter) { return 'TemperaturaInputFormatter'; } @@ -951,8 +958,17 @@ List<_FormatterDemo> _createFormatterDemos() => [ '0601064-21.2022.6.00.0000', NUPInputFormatter()), _demo('cns', 'Documentos e identificadores', 'CNS', '000 1111 2222 3333', CNSInputFormatter()), - _demo('telefone', 'Contato, tempo e mobilidade', 'Telefone', - '(11) 98765-4321', TelefoneInputFormatter()), + _demo('telefone-fixo', 'Contato, tempo e mobilidade', 'Telefone fixo', + '(11) 3456-7890', TelefoneFixoInputFormatter()), + _demo('celular', 'Contato, tempo e mobilidade', 'Celular', + '(11) 98765-4321', CelularInputFormatter()), + _demo( + 'telefone-ou-celular', + 'Contato, tempo e mobilidade', + 'Telefone ou celular', + 'Aceita 10 ou 11 dígitos', + TelefoneOuCelularInputFormatter(), + ), _demo('data', 'Contato, tempo e mobilidade', 'Data', '21/09/2026', DataInputFormatter()), _demo('hora', 'Contato, tempo e mobilidade', 'Hora', '14:35', diff --git a/example/test/example_page_test.dart b/example/test/example_page_test.dart index 6c75e0d..3ec9fe2 100644 --- a/example/test/example_page_test.dart +++ b/example/test/example_page_test.dart @@ -48,6 +48,46 @@ void main() { expect(editable.controller.text, '12.345-678'); }); + testWidgets('shows and formats fixed, mobile and automatic phones', + (tester) async { + await tester.pumpWidget(const BrasilFieldsApp()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const Key('formatter-search')), + 'mobilidade', + ); + await tester.pumpAndSettle(); + await tester.drag( + find.byKey(const PageStorageKey('formatters-page')), + const Offset(0, -500), + ); + await tester.pumpAndSettle(); + + final fixed = find.byKey(const Key('formatter-telefone-fixo')); + final mobile = find.byKey(const Key('formatter-celular')); + final automatic = find.byKey(const Key('formatter-telefone-ou-celular')); + expect(fixed, findsOneWidget); + expect(mobile, findsOneWidget); + expect(automatic, findsOneWidget); + + await tester.enterText(fixed, '1134567890'); + await tester.enterText(mobile, '11987654321'); + await tester.enterText(automatic, '11987654321'); + await tester.pump(); + + String textIn(Finder field) => tester + .widget( + find.descendant(of: field, matching: find.byType(EditableText)), + ) + .controller + .text; + + expect(textIn(fixed), '(11) 3456-7890'); + expect(textIn(mobile), '(11) 98765-4321'); + expect(textIn(automatic), '(11) 98765-4321'); + }); + testWidgets('search filters formatters and alphanumeric input is formatted', (tester) async { await tester.pumpWidget(const BrasilFieldsApp()); diff --git a/lib/brasil_fields.dart b/lib/brasil_fields.dart index 73104a1..63f4ac5 100644 --- a/lib/brasil_fields.dart +++ b/lib/brasil_fields.dart @@ -2,6 +2,7 @@ library; export 'src/formatters/altura_input_formatter.dart'; export 'src/formatters/cartao_bancario_input_formatter.dart'; +export 'src/formatters/celular_input_formatter.dart'; export 'src/formatters/centavos_input_formatter.dart'; export 'src/formatters/cep_input_formatter.dart'; export 'src/formatters/cert_nascimento_input_formatter.dart'; @@ -23,6 +24,8 @@ export 'src/formatters/pis_pasep_input_formatter.dart'; export 'src/formatters/placa_veiculo_formatter.dart'; export 'src/formatters/real_input_formatter.dart'; export 'src/formatters/telefone_input_formatter.dart'; +export 'src/formatters/telefone_fixo_input_formatter.dart'; +export 'src/formatters/telefone_ou_celular_input_formatter.dart'; export 'src/formatters/temperatura_input_formatter.dart'; export 'src/formatters/validade_cartao_input_formatter.dart'; export 'src/modelos/estados.dart'; diff --git a/lib/src/formatters/celular_input_formatter.dart b/lib/src/formatters/celular_input_formatter.dart new file mode 100644 index 0000000..3bb9df0 --- /dev/null +++ b/lib/src/formatters/celular_input_formatter.dart @@ -0,0 +1,10 @@ +import 'telefone_mask_input_formatter.dart'; + +/// Formata celular com até 11 dígitos: `(XX) XXXXX-XXXX`. +class CelularInputFormatter extends TelefoneMaskInputFormatter { + @override + int get maxLength => 11; + + @override + int get digitosAntesDoHifen => 5; +} diff --git a/lib/src/formatters/telefone_fixo_input_formatter.dart b/lib/src/formatters/telefone_fixo_input_formatter.dart new file mode 100644 index 0000000..e91fe39 --- /dev/null +++ b/lib/src/formatters/telefone_fixo_input_formatter.dart @@ -0,0 +1,10 @@ +import 'telefone_mask_input_formatter.dart'; + +/// Formata telefone fixo com até 10 dígitos: `(XX) XXXX-XXXX`. +class TelefoneFixoInputFormatter extends TelefoneMaskInputFormatter { + @override + int get maxLength => 10; + + @override + int get digitosAntesDoHifen => 4; +} diff --git a/lib/src/formatters/telefone_input_formatter.dart b/lib/src/formatters/telefone_input_formatter.dart index 7d155f4..327b17a 100644 --- a/lib/src/formatters/telefone_input_formatter.dart +++ b/lib/src/formatters/telefone_input_formatter.dart @@ -1,57 +1,23 @@ import 'package:flutter/services.dart'; -/// Formata o valor do campo como telefone fixo ou celular. +import 'telefone_ou_celular_input_formatter.dart'; + +/// Formata telefone fixo ou celular conforme a quantidade de dígitos. /// -/// Usa a máscara `(XX) XXXX-XXXX` para 10 dígitos e `(XX) 9XXXX-XXXX` para -/// 11 dígitos. Nesse caso, exige `9` como o primeiro dígito do número. -class TelefoneInputFormatter extends TextInputFormatter { +/// Este nome legado mantém o comportamento automático existente. Para escolher +/// o tipo explicitamente, use `TelefoneFixoInputFormatter` ou +/// `CelularInputFormatter`. Para 11 dígitos, também mantém a regra histórica de +/// exigir `9` como primeiro dígito após o DDD. +class TelefoneInputFormatter extends TelefoneOuCelularInputFormatter { @override TextEditingValue formatEditUpdate( - TextEditingValue oldValue, TextEditingValue newValue) { + TextEditingValue oldValue, + TextEditingValue newValue, + ) { if (!newValue.composing.isCollapsed) return newValue; - - if (newValue.text.length > 11) return oldValue; - - final newValueLength = newValue.text.length; - if (newValueLength == 11) { - if (newValue.text[2] != '9') { - return oldValue; - } + if (newValue.text.length == 11 && newValue.text[2] != '9') { + return oldValue; } - - var posicaoCursor = newValue.selection.end; - var substrIndex = 0; - final valorFinal = StringBuffer(); - - if (newValueLength >= 1) { - valorFinal.write('('); - if (newValue.selection.end >= 1) posicaoCursor++; - } - - if (newValueLength >= 3) { - valorFinal.write('${newValue.text.substring(0, substrIndex = 2)}) '); - if (newValue.selection.end >= 2) posicaoCursor += 2; - } - - if (newValue.text.length == 11) { - if (newValueLength >= 8) { - valorFinal.write('${newValue.text.substring(2, substrIndex = 7)}-'); - if (newValue.selection.end >= 7) posicaoCursor++; - } - } else { - if (newValueLength >= 7) { - valorFinal.write('${newValue.text.substring(2, substrIndex = 6)}-'); - if (newValue.selection.end >= 6) posicaoCursor++; - } - } - - if (newValueLength >= substrIndex) { - valorFinal.write(newValue.text.substring(substrIndex)); - } - - return TextEditingValue( - text: valorFinal.toString(), - selection: TextSelection.collapsed(offset: posicaoCursor), - ); + return super.formatEditUpdate(oldValue, newValue); } } diff --git a/lib/src/formatters/telefone_mask_input_formatter.dart b/lib/src/formatters/telefone_mask_input_formatter.dart new file mode 100644 index 0000000..ad9cf89 --- /dev/null +++ b/lib/src/formatters/telefone_mask_input_formatter.dart @@ -0,0 +1,65 @@ +import 'package:flutter/services.dart'; + +final _digitPattern = RegExp(r'\d'); + +/// Aplica uma máscara telefônica limitada a uma quantidade de dígitos. +abstract class TelefoneMaskInputFormatter extends TextInputFormatter { + /// Limite de dígitos aceitos pelo formatador. + int get maxLength; + + /// Quantidade de dígitos do assinante antes do hífen. + int get digitosAntesDoHifen; + + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + if (!newValue.composing.isCollapsed) return newValue; + if (newValue.text.length > maxLength) return oldValue; + + var substrIndex = 0; + final valorFinal = StringBuffer(); + + if (newValue.text.isNotEmpty) { + valorFinal.write('('); + } + + if (newValue.text.length >= 3) { + valorFinal.write('${newValue.text.substring(0, substrIndex = 2)}) '); + } + + final limiteHifen = 2 + digitosAntesDoHifen; + if (newValue.text.length > limiteHifen) { + valorFinal.write( + '${newValue.text.substring(2, substrIndex = limiteHifen)}-', + ); + } + + if (newValue.text.length >= substrIndex) { + valorFinal.write(newValue.text.substring(substrIndex)); + } + + final textoFormatado = valorFinal.toString(); + final selectionEnd = newValue.selection.end.clamp(0, newValue.text.length); + final digitosAntesDoCursor = _digitPattern + .allMatches(newValue.text.substring(0, selectionEnd)) + .length; + var digitosPercorridos = 0; + var posicaoCursor = 0; + if (digitosAntesDoCursor > 0) { + for (var i = 0; i < textoFormatado.length; i++) { + if (_digitPattern.hasMatch(textoFormatado[i])) { + digitosPercorridos++; + posicaoCursor = i + 1; + if (digitosPercorridos == digitosAntesDoCursor) break; + } + } + } + + return TextEditingValue( + text: textoFormatado, + selection: TextSelection.collapsed(offset: posicaoCursor), + ); + } +} diff --git a/lib/src/formatters/telefone_ou_celular_input_formatter.dart b/lib/src/formatters/telefone_ou_celular_input_formatter.dart new file mode 100644 index 0000000..e5ca51a --- /dev/null +++ b/lib/src/formatters/telefone_ou_celular_input_formatter.dart @@ -0,0 +1,24 @@ +import 'package:flutter/services.dart'; + +import 'celular_input_formatter.dart'; +import 'telefone_fixo_input_formatter.dart'; + +/// Formata telefone fixo ou celular conforme a quantidade de dígitos. +/// +/// Até 10 dígitos, usa a máscara de telefone fixo. Com 11 dígitos, usa a +/// máscara de celular. +class TelefoneOuCelularInputFormatter extends TextInputFormatter { + final _telefoneFixo = TelefoneFixoInputFormatter(); + final _celular = CelularInputFormatter(); + + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + if (!newValue.composing.isCollapsed) return newValue; + return newValue.text.length <= _telefoneFixo.maxLength + ? _telefoneFixo.formatEditUpdate(oldValue, newValue) + : _celular.formatEditUpdate(oldValue, newValue); + } +} diff --git a/test/src/formatters/telefone_input_formatter_test.dart b/test/src/formatters/telefone_input_formatter_test.dart index a6e8587..2142971 100644 --- a/test/src/formatters/telefone_input_formatter_test.dart +++ b/test/src/formatters/telefone_input_formatter_test.dart @@ -22,6 +22,14 @@ void main() { evaluate(textEditingValue(''), textEditingValue('6187654321')).text, '(61) 8765-4321')); + test('mantém a regra legada do nono dígito para celulares', () { + final oldValue = textEditingValue('(61) 1234-5678'); + expect( + evaluate(oldValue, textEditingValue('61123456789')), + oldValue, + ); + }); + test('limite 11 digitos', () { final oldValue = textEditingValue('(61) 98765-4321'); expect( @@ -51,6 +59,139 @@ void main() { formatters, textEditingValue('61'), value); }); }); + + group('TelefoneFixoInputFormatter', () { + final fixo = numericFormatterChain(TelefoneFixoInputFormatter()); + + test('formata até 10 dígitos e limita o tamanho', () { + expect( + applyFormatterChain( + fixo, + textEditingValue(''), + textEditingValue('6187654321'), + ).text, + '(61) 8765-4321', + ); + + final oldValue = textEditingValue('(61) 8765-4321'); + expect( + applyFormatterChain(fixo, oldValue, textEditingValue('61876543210')), + oldValue, + ); + }); + + test('cursor permanece no fim durante a digitação', () { + var value = textEditingValue(''); + for (final digit in '6187654321'.split('')) { + value = applyFormatterChain( + fixo, + value, + textEditingValue('${value.text.replaceAll(RegExp(r'\D'), '')}$digit'), + ); + expectCollapsedSelectionAtEnd(value); + } + expect(value.text, '(61) 8765-4321'); + }); + + test('preserva composição ativa', () { + final value = + textEditingValue('619', composing: const TextRange(start: 0, end: 3)); + expectActiveComposingIsUnchanged(fixo, textEditingValue('61'), value); + }); + + test('cursor avança sobre dígitos ao apagar separadores da máscara', () { + final oldValue = textEditingValue('(61) 9876-5'); + final afterParenthesis = applyFormatterChain( + fixo, + oldValue, + textEditingValue( + '6198765', + selection: const TextSelection.collapsed(offset: 2), + ), + ); + expect(afterParenthesis.text, '(61) 9876-5'); + expect( + afterParenthesis.selection, const TextSelection.collapsed(offset: 3)); + + final afterHyphen = applyFormatterChain( + fixo, + oldValue, + textEditingValue( + '6198765', + selection: const TextSelection.collapsed(offset: 6), + ), + ); + expect(afterHyphen.text, '(61) 9876-5'); + expect(afterHyphen.selection, const TextSelection.collapsed(offset: 9)); + }); + }); + + group('CelularInputFormatter', () { + final celular = numericFormatterChain(CelularInputFormatter()); + + test('aplica a máscara móvel desde o início e aceita 11 dígitos', () { + expect( + applyFormatterChain( + celular, + textEditingValue(''), + textEditingValue('6198765'), + ).text, + '(61) 98765', + ); + expect( + applyFormatterChain( + celular, + textEditingValue(''), + textEditingValue('61987654321'), + ).text, + '(61) 98765-4321', + ); + }); + + test('limita a 11 dígitos e preserva composição ativa', () { + final oldValue = textEditingValue('(61) 98765-4321'); + expect( + applyFormatterChain( + celular, oldValue, textEditingValue('619876543210')), + oldValue, + ); + + final value = + textEditingValue('619', composing: const TextRange(start: 0, end: 3)); + expectActiveComposingIsUnchanged(celular, textEditingValue('61'), value); + }); + + test('permite apagar dígitos sem perder o texto restante', () { + expect( + applyFormatterChain( + celular, + textEditingValue('(61) 98765-4321'), + textEditingValue('6198765432'), + ).text, + '(61) 98765-432', + ); + }); + }); + + test('TelefoneOuCelularInputFormatter escolhe a máscara pelo tamanho', () { + final automatico = numericFormatterChain(TelefoneOuCelularInputFormatter()); + expect( + applyFormatterChain( + automatico, + textEditingValue(''), + textEditingValue('6187654321'), + ).text, + '(61) 8765-4321', + ); + expect( + applyFormatterChain( + automatico, + textEditingValue(''), + textEditingValue('61987654321'), + ).text, + '(61) 98765-4321', + ); + }); } const _telefoneCases = {