Use dedicated birthday entry controls (#67)

This commit is contained in:
Stephan D committed 2026-10-23 12:19:24 +02:00
1 parent f1ff40d072
commit 4605a197ad
7 files changed
+551 -13

No files matched your search

+47
View File
@@ -211,3 +211,50 @@ smoke testing on the local demo opened the selector, searched for Poland and
confirmed that the profile displays Poland after selection. Focused and full
journey tests verify the stored `PL` code. Generated localization/build outputs
remain ignored and uncommitted.
## Birthday entry — task #67
Task [#67](https://git.sendico.io/tech/sxchnge/issues/67), branch `kyc-67`.
This change is stacked on the residence-country selector in PR #65; merge #65
before the birthday PR.
The profile uses labeled Day, Month and Year controls. Day and year accept numeric
entry with birthday autofill hints; month selection uses localized month names
and standard Material keyboard/touch interaction. Customers can enter their birth
year directly. No calendar paging or transport-format punctuation is required.
The controls share a row when space allows and stack at narrow widths or enlarged
text sizes. English and Russian labels, month names and errors follow the app locale.
Valid calendar dates are normalized to the existing `IdentityProfile.dateOfBirth`
`YYYY-MM-DD` value through `IdentityProvider.updateProfile`. Partial, impossible
and future dates clear that profile value so Continue cannot retain an earlier
valid birthday. Local input components remain visible while correcting a date;
complete invalid dates receive a specific live-region error below the group.
Leap years, zero day/year and calendar rollover are checked. Today's date follows
the existing model policy; no minimum age or arbitrary oldest birth year is added.
Prefilled dates, locale changes and external profile replacement synchronize the
controls. Back navigation restores the valid saved date. Busy state disables all
three controls. Controllers are disposed with the widget.
The existing DTO, mapper, service, domain model and provider boundaries are retained.
No dependency, transport or backend change is needed. This remains part of the KYC
preparation-to-submission journey defined above: existing acceptance, failure,
retry and abandonment signals apply, without new field events or date collection.
The existing server-side review measurement gaps remain unchanged.
UX review and automated checks cover entry/correction, leap dates, invalid and
future-date recovery, numeric restrictions, prefill/replacement/clearing, locale
switching, disabled state, Tab/Enter/arrow-key month selection, Escape cancellation,
and Russian controls/errors at 320px with 1.6 text scale. Labeled and minimum-size
tap-target guidelines pass. Integration checks verify that invalid dates block
Continue, Back preserves the birthday, and EN/RU flows still reach submission
acceptance. Native screen-reader and mobile keyboard/autofill testing remain
release checks; widget semantics are not a claim of device-level validation.
Validation: all four changed Dart files pass the format check; `flutter analyze`
in `frontend/app` reports no issues; `make test-frontend` passes all 230 tests,
including the seven new birthday widget tests and the new navigation/gating test.
`flutter build web` passes, including its Wasm compatibility dry run. Browser
smoke testing of the release build entered 23 April 1990, verified the inline
error for 31 April and restored 23 April without losing month or year.
Generated localization and build outputs remain ignored and uncommitted.
+206
View File
@@ -0,0 +1,206 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import '../generated/i18n/app_localizations.dart';
/// Birthday entry with a canonical date string at the profile boundary.
class BirthField extends StatefulWidget {
const BirthField({
super.key,
required this.value,
required this.onChanged,
this.enabled = true,
});
final String value;
final ValueChanged<String> onChanged;
final bool enabled;
@override
State<BirthField> createState() => _BirthFieldState();
}
class _BirthFieldState extends State<BirthField> {
final _day = TextEditingController();
final _year = TextEditingController();
final _monthField = GlobalKey<FormFieldState<int>>();
int? _month;
late String _emitted;
@override
void initState() {
super.initState();
_readValue();
}
@override
void didUpdateWidget(BirthField oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.value != _emitted) {
_readValue();
_monthField.currentState?.didChange(_month);
}
}
void _readValue() {
_emitted = widget.value;
final parts = RegExp(r'^(\d{4})-(\d{2})-(\d{2})$').firstMatch(widget.value);
_year.text = parts?.group(1) ?? '';
final month = int.tryParse(parts?.group(2) ?? '');
_month = month != null && month >= 1 && month <= 12 ? month : null;
_day.text = parts?.group(3) ?? '';
}
DateTime? get _date {
final day = int.tryParse(_day.text);
final year = int.tryParse(_year.text);
if (day == null ||
year == null ||
year < 1 ||
_year.text.length != 4 ||
_month == null) {
return null;
}
final date = DateTime(year, _month!, day);
return date.year == year && date.month == _month && date.day == day
? date
: null;
}
bool _future(DateTime date) =>
date.isAfter(DateUtils.dateOnly(DateTime.now()));
void _change() {
setState(() {});
final date = _date;
_emitted = date == null || _future(date)
? ''
: '${_year.text}-${_month.toString().padLeft(2, '0')}-'
'${_day.text.padLeft(2, '0')}';
widget.onChanged(_emitted);
}
@override
void dispose() {
_day.dispose();
_year.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
final date = _date;
final complete =
_day.text.isNotEmpty && _month != null && _year.text.length == 4;
final error = !complete
? null
: date == null
? l.kycBirthInvalid
: _future(date)
? l.kycBirthFuture
: null;
final months = DateFormat.MMMM(Localizations.localeOf(context).toString());
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(l.kycBirth, style: Theme.of(context).textTheme.labelLarge),
const SizedBox(height: 8),
LayoutBuilder(
builder: (context, constraints) {
final day = _number(
key: 'birth-day',
controller: _day,
label: l.kycBirthDay,
length: 2,
autofill: AutofillHints.birthdayDay,
);
final month = DropdownButtonFormField<int>(
key: _monthField,
initialValue: _month,
isExpanded: true,
decoration: InputDecoration(labelText: l.kycBirthMonth),
items: [
for (var month = 1; month <= 12; month++)
DropdownMenuItem(
value: month,
child: Text(months.format(DateTime(2000, month))),
),
],
onChanged: !widget.enabled
? null
: (value) {
_month = value;
_change();
},
);
final year = _number(
key: 'birth-year',
controller: _year,
label: l.kycBirthYear,
length: 4,
autofill: AutofillHints.birthdayYear,
);
final scale = MediaQuery.textScalerOf(context).scale(16) / 16;
if (constraints.maxWidth < 400 * scale) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
day,
const SizedBox(height: 16),
month,
const SizedBox(height: 16),
year,
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(flex: 2, child: day),
const SizedBox(width: 12),
Expanded(flex: 4, child: month),
const SizedBox(width: 12),
Expanded(flex: 3, child: year),
],
);
},
),
if (error != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Semantics(
liveRegion: true,
child: Text(
error,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
),
],
);
}
Widget _number({
required String key,
required TextEditingController controller,
required String label,
required int length,
required String autofill,
}) => TextFormField(
key: ValueKey(key),
controller: controller,
enabled: widget.enabled,
keyboardType: TextInputType.number,
textInputAction: TextInputAction.next,
autofillHints: [autofill],
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(length),
],
decoration: InputDecoration(labelText: label),
onChanged: (_) => _change(),
);
}
+4 -9
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
import 'birth.dart';
import 'country.dart';
import '../generated/i18n/app_localizations.dart';
@@ -37,17 +38,11 @@ class ProfileForm extends StatelessWidget {
onChanged: (value) => update(name: value),
),
const SizedBox(height: 16),
TextFormField(
BirthField(
key: const ValueKey('kyc-birth'),
initialValue: p.profile.dateOfBirth,
value: p.profile.dateOfBirth,
enabled: !p.busy,
keyboardType: TextInputType.datetime,
autofillHints: const [AutofillHints.birthday],
decoration: InputDecoration(
labelText: l.kycBirth,
helperText: l.kycBirthHelp,
),
onChanged: (value) => update(birth: value.trim()),
onChanged: (value) => update(birth: value),
),
const SizedBox(height: 16),
CountryField(
+5 -1
View File
@@ -269,7 +269,11 @@
"kycProfileHelp": "Enter your details exactly as shown on your identity document.",
"kycFullName": "Full legal name",
"kycBirth": "Date of birth",
"kycBirthHelp": "YYYY-MM-DD, for example 1990-04-23",
"kycBirthDay": "Day",
"kycBirthMonth": "Month",
"kycBirthYear": "Year",
"kycBirthInvalid": "Enter a valid date of birth.",
"kycBirthFuture": "Date of birth cannot be in the future.",
"kycCountry": "Country of residence",
"kycCountrySelect": "Select a country",
"kycCountrySearch": "Search countries",
+5 -1
View File
@@ -269,7 +269,11 @@
"kycProfileHelp": "Укажите данные точно как в документе, удостоверяющем личность.",
"kycFullName": "Полное имя по документу",
"kycBirth": "Дата рождения",
"kycBirthHelp": "ГГГГ-ММ-ДД, например 1990-04-23",
"kycBirthDay": "День",
"kycBirthMonth": "Месяц",
"kycBirthYear": "Год",
"kycBirthInvalid": "Укажите корректную дату рождения.",
"kycBirthFuture": "Дата рождения не может быть в будущем.",
"kycCountry": "Страна проживания",
"kycCountrySelect": "Выберите страну",
"kycCountrySearch": "Поиск страны",
+236
View File
@@ -0,0 +1,236 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sxchnge_theme/sxchnge_theme.dart';
import 'package:sxchnge_app/kyc/birth.dart';
import 'package:sxchnge_app/generated/i18n/app_localizations.dart';
void main() {
final day = find.byKey(const ValueKey('birth-day'));
final year = find.byKey(const ValueKey('birth-year'));
final month = find.byType(DropdownButtonFormField<int>);
Future<void> mount(
WidgetTester t,
ValueNotifier<String> value, {
Locale locale = const Locale('en'),
bool enabled = true,
}) async {
await t.pumpWidget(
MaterialApp(
theme: SxchngeTheme.light(),
locale: locale,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ValueListenableBuilder<String>(
valueListenable: value,
builder: (_, date, _) => BirthField(
value: date,
enabled: enabled,
onChanged: (date) => value.value = date,
),
),
),
),
),
);
await t.pumpAndSettle();
}
Future<void> select(WidgetTester t, String name) async {
await t.ensureVisible(month);
await t.tap(month);
await t.pumpAndSettle();
await t.tap(find.text(name).last);
await t.pumpAndSettle();
}
testWidgets('enters and corrects a birthday without ISO syntax', (t) async {
final value = ValueNotifier('');
addTearDown(value.dispose);
await mount(t, value);
expect(find.text('Date of birth'), findsOneWidget);
await t.enterText(day, '23');
await select(t, 'April');
expect(value.value, '');
await t.enterText(year, '1990');
await t.pumpAndSettle();
expect(value.value, '1990-04-23');
await t.enterText(day, '9');
await t.pumpAndSettle();
expect(value.value, '1990-04-09');
await t.enterText(year, '19');
await t.pumpAndSettle();
expect(value.value, '');
// A provider rebuild must retain the other components of an incomplete date.
expect(t.widget<TextFormField>(day).controller!.text, '9');
expect(find.text('April'), findsOneWidget);
await t.enterText(year, '1985');
await t.pumpAndSettle();
expect(value.value, '1985-04-09');
});
testWidgets('leap dates, impossible days and future dates recover inline', (
t,
) async {
final value = ValueNotifier('2000-02-29');
addTearDown(value.dispose);
await mount(t, value);
expect(find.text('February'), findsOneWidget);
await t.enterText(year, '2001');
await t.pumpAndSettle();
expect(value.value, '');
expect(find.text('Enter a valid date of birth.'), findsOneWidget);
expect(t.widget<TextFormField>(day).controller!.text, '29');
await t.enterText(year, '2004');
await t.pumpAndSettle();
expect(value.value, '2004-02-29');
expect(find.text('Enter a valid date of birth.'), findsNothing);
await t.enterText(day, '31');
await select(t, 'April');
expect(value.value, '');
expect(find.text('Enter a valid date of birth.'), findsOneWidget);
await t.enterText(day, '30');
await t.enterText(year, '${DateTime.now().year + 1}');
await t.pumpAndSettle();
expect(value.value, '');
expect(find.text('Date of birth cannot be in the future.'), findsOneWidget);
await t.enterText(year, '1990');
await t.pumpAndSettle();
expect(value.value, '1990-04-30');
await t.enterText(day, '0');
await t.pumpAndSettle();
expect(value.value, '');
expect(find.text('Enter a valid date of birth.'), findsOneWidget);
await t.enterText(day, '1');
await t.enterText(year, '0000');
await t.pumpAndSettle();
expect(value.value, '');
expect(find.text('Enter a valid date of birth.'), findsOneWidget);
});
testWidgets(
'prefill, external replacement, locale change and clear synchronize',
(t) async {
final value = ValueNotifier('1990-04-23');
addTearDown(value.dispose);
await mount(t, value);
expect(t.widget<TextFormField>(year).controller!.text, '1990');
value.value = '1985-12-09';
await t.pumpAndSettle();
expect(t.widget<TextFormField>(day).controller!.text, '09');
expect(t.widget<TextFormField>(year).controller!.text, '1985');
expect(find.text('December'), findsOneWidget);
await mount(t, value, locale: const Locale('ru'));
expect(find.text('Дата рождения'), findsOneWidget);
expect(find.text('декабрь'), findsOneWidget);
expect(value.value, '1985-12-09');
await select(t, 'апрель');
expect(value.value, '1985-04-09');
value.value = '';
await t.pumpAndSettle();
expect(t.widget<TextFormField>(day).controller!.text, '');
expect(t.widget<TextFormField>(year).controller!.text, '');
expect(find.text('апрель'), findsNothing);
},
);
testWidgets('digits and length limits keep each component usable', (t) async {
final value = ValueNotifier('1990-04-23');
addTearDown(value.dispose);
await mount(t, value);
await t.enterText(day, '');
await t.enterText(day, 'a123');
await t.enterText(year, '');
await t.enterText(year, 'b19856');
await t.pumpAndSettle();
expect(t.widget<TextFormField>(day).controller!.text, '12');
expect(t.widget<TextFormField>(year).controller!.text, '1985');
expect(value.value, '1985-04-12');
});
testWidgets('busy state disables every birthday control', (t) async {
final value = ValueNotifier('1990-04-23');
addTearDown(value.dispose);
await mount(t, value, enabled: false);
expect(t.widget<TextFormField>(day).enabled, isFalse);
expect(t.widget<TextFormField>(year).enabled, isFalse);
expect(t.widget<DropdownButtonFormField<int>>(month).onChanged, isNull);
expect(value.value, '1990-04-23');
});
testWidgets('keyboard can traverse, choose month, cancel and edit year', (
t,
) async {
final value = ValueNotifier('1990-04-23');
addTearDown(value.dispose);
await mount(t, value);
await t.sendKeyEvent(LogicalKeyboardKey.tab);
await t.pump();
expect(
t
.widget<EditableText>(
find.descendant(of: day, matching: find.byType(EditableText)),
)
.focusNode
.hasFocus,
isTrue,
);
await t.sendKeyEvent(LogicalKeyboardKey.tab);
await t.sendKeyEvent(LogicalKeyboardKey.enter);
await t.pumpAndSettle();
await t.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await t.sendKeyEvent(LogicalKeyboardKey.enter);
await t.pumpAndSettle();
expect(value.value, '1990-05-23');
await t.sendKeyEvent(LogicalKeyboardKey.enter);
await t.pumpAndSettle();
await t.sendKeyEvent(LogicalKeyboardKey.escape);
await t.pumpAndSettle();
expect(value.value, '1990-05-23');
await t.sendKeyEvent(LogicalKeyboardKey.tab);
await t.pump();
expect(
t
.widget<EditableText>(
find.descendant(of: year, matching: find.byType(EditableText)),
)
.focusNode
.hasFocus,
isTrue,
);
});
testWidgets(
'Russian controls and errors fit 320px with large text and labeled targets',
(t) async {
t.view.physicalSize = const Size(320, 900);
t.view.devicePixelRatio = 1;
t.platformDispatcher.textScaleFactorTestValue = 1.6;
addTearDown(t.view.resetPhysicalSize);
addTearDown(t.view.resetDevicePixelRatio);
addTearDown(t.platformDispatcher.clearTextScaleFactorTestValue);
final semantics = t.ensureSemantics();
final value = ValueNotifier('1990-09-23');
addTearDown(value.dispose);
await mount(t, value, locale: const Locale('ru'));
expect(find.text('сентябрь'), findsOneWidget);
expect(t, meetsGuideline(labeledTapTargetGuideline));
expect(t, meetsGuideline(androidTapTargetGuideline));
await t.enterText(day, '31');
await t.pumpAndSettle();
expect(find.text('Укажите корректную дату рождения.'), findsOneWidget);
await select(t, 'март');
expect(value.value, '1990-03-31');
expect(t.takeException(), isNull);
semantics.dispose();
},
);
}
+48 -2
View File
@@ -11,6 +11,7 @@ import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
import 'package:sxchnge_app/kyc/birth.dart';
import 'package:sxchnge_app/kyc/country.dart';
import 'package:sxchnge_app/main.dart';
import 'package:sxchnge_app/providers/locale.dart';
@@ -66,10 +67,16 @@ void main() {
return service;
}
Future<void> submit(WidgetTester t) async {
Future<void> profile(WidgetTester t) async {
await tap(t, find.byKey(const ValueKey('kyc-start')));
await t.enterText(find.byKey(const ValueKey('kyc-name')), 'Example Person');
await t.enterText(find.byKey(const ValueKey('kyc-birth')), '1990-04-23');
await t.enterText(find.byKey(const ValueKey('birth-day')), '23');
await tap(t, find.byType(DropdownButtonFormField<int>));
// The month label follows the selected app locale.
await tap(t, find.textContaining(RegExp(r'^(April|апрель)$')).last);
await t.enterText(find.byKey(const ValueKey('birth-year')), '1990');
await t.pumpAndSettle();
expect(t.widget<BirthField>(find.byType(BirthField)).value, '1990-04-23');
await tap(
t,
find.descendant(
@@ -85,6 +92,10 @@ void main() {
'PL',
);
await t.pumpAndSettle();
}
Future<void> submit(WidgetTester t) async {
await profile(t);
await tap(t, find.byKey(const ValueKey('pick-passport')));
await tap(t, find.byKey(const ValueKey('kyc-next')));
await tap(t, find.byKey(const ValueKey('pick-selfie')));
@@ -93,6 +104,41 @@ void main() {
await tap(t, find.byKey(const ValueKey('kyc-submit')));
}
testWidgets(
'invalid birthday blocks advancing and valid birthday survives Back',
(t) async {
await open(t);
await profile(t);
await tap(t, find.byKey(const ValueKey('pick-passport')));
final next = find.byKey(const ValueKey('kyc-next'));
await t.enterText(find.byKey(const ValueKey('birth-day')), '31');
await t.pumpAndSettle();
expect(t.widget<FilledButton>(next).onPressed, isNull);
expect(find.text('Enter a valid date of birth.'), findsOneWidget);
await t.enterText(find.byKey(const ValueKey('birth-day')), '23');
await t.pumpAndSettle();
expect(t.widget<FilledButton>(next).onPressed, isNotNull);
await tap(t, next);
await tap(t, find.text('Back'));
expect(t.widget<BirthField>(find.byType(BirthField)).value, '1990-04-23');
expect(
t
.widget<TextFormField>(find.byKey(const ValueKey('birth-day')))
.controller!
.text,
'23',
);
expect(find.text('April'), findsOneWidget);
expect(
t
.widget<TextFormField>(find.byKey(const ValueKey('birth-year')))
.controller!
.text,
'1990',
);
},
);
testWidgets('two photos, review, pending and approval with service limits', (
t,
) async {