diff --git a/.config/dictionaries/project.dic b/.config/dictionaries/project.dic index afc2dd89a75..d96cd5b2277 100644 --- a/.config/dictionaries/project.dic +++ b/.config/dictionaries/project.dic @@ -22,6 +22,7 @@ auditability Autolayout autorecalculates autoresizing +autovalidate backendpython bech bimap diff --git a/catalyst_voices/.gitignore b/catalyst_voices/.gitignore index 870c49c28a0..361c63c808d 100644 --- a/catalyst_voices/.gitignore +++ b/catalyst_voices/.gitignore @@ -1,5 +1,6 @@ ### Dart ### # See https://www.dartlang.org/guides/libraries/private-files +devtools_options.yaml # Generated files from code generation tools diff --git a/catalyst_voices/apps/voices/lib/common/ext/time_of_day_ext.dart b/catalyst_voices/apps/voices/lib/common/ext/time_of_day_ext.dart new file mode 100644 index 00000000000..8bd65d9a602 --- /dev/null +++ b/catalyst_voices/apps/voices/lib/common/ext/time_of_day_ext.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +extension TimeOfDayExt on TimeOfDay { + String get formatted { + final hour = this.hour.toString().padLeft(2, '0'); + final minute = this.minute.toString().padLeft(2, '0'); + + return '$hour:$minute'; + } +} diff --git a/catalyst_voices/apps/voices/lib/widgets/pickers/voices_calendar_picker.dart b/catalyst_voices/apps/voices/lib/widgets/pickers/voices_calendar_picker.dart new file mode 100644 index 00000000000..591253add14 --- /dev/null +++ b/catalyst_voices/apps/voices/lib/widgets/pickers/voices_calendar_picker.dart @@ -0,0 +1,93 @@ +import 'package:catalyst_voices/widgets/buttons/voices_text_button.dart'; +import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; +import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; +import 'package:flutter/material.dart'; + +class VoicesCalendarDatePicker extends StatefulWidget { + final ValueChanged onDateSelected; + final VoidCallback cancelEvent; + final DateTime initialDate; + final DateTime firstDate; + final DateTime lastDate; + + factory VoicesCalendarDatePicker({ + Key? key, + required ValueChanged onDateSelected, + required VoidCallback cancelEvent, + DateTime? initialDate, + DateTime? firstDate, + DateTime? lastDate, + }) { + final now = DateTime.now(); + return VoicesCalendarDatePicker._( + key: key, + onDateSelected: onDateSelected, + initialDate: initialDate ?? now, + firstDate: firstDate ?? now, + lastDate: lastDate ?? DateTime(now.year + 1, now.month, now.day), + cancelEvent: cancelEvent, + ); + } + + const VoicesCalendarDatePicker._({ + super.key, + required this.onDateSelected, + required this.initialDate, + required this.firstDate, + required this.lastDate, + required this.cancelEvent, + }); + + @override + State createState() => + _VoicesCalendarDatePickerState(); +} + +class _VoicesCalendarDatePickerState extends State { + DateTime selectedDate = DateTime.now(); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 450, + child: Material( + clipBehavior: Clip.hardEdge, + color: Colors.transparent, + child: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colors.elevationsOnSurfaceNeutralLv1Grey, + borderRadius: BorderRadius.circular(20), + ), + child: Column( + children: [ + CalendarDatePicker( + initialDate: widget.initialDate, + firstDate: widget.firstDate, + lastDate: widget.lastDate, + onDateChanged: (val) { + selectedDate = val; + }, + ), + Padding( + padding: const EdgeInsets.all(8), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + VoicesTextButton( + onTap: widget.cancelEvent, + child: Text(context.l10n.cancelButtonText), + ), + VoicesTextButton( + onTap: () => widget.onDateSelected(selectedDate), + child: Text(context.l10n.ok.toUpperCase()), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/catalyst_voices/apps/voices/lib/widgets/pickers/voices_time_picker.dart b/catalyst_voices/apps/voices/lib/widgets/pickers/voices_time_picker.dart new file mode 100644 index 00000000000..36c79ccaf4e --- /dev/null +++ b/catalyst_voices/apps/voices/lib/widgets/pickers/voices_time_picker.dart @@ -0,0 +1,141 @@ +import 'package:catalyst_voices/common/ext/time_of_day_ext.dart'; +import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; +import 'package:flutter/material.dart'; + +class VoicesTimePicker extends StatefulWidget { + final ValueChanged onTap; + final TimeOfDay? selectedTime; + final String? timeZone; + + const VoicesTimePicker({ + super.key, + required this.onTap, + this.selectedTime, + this.timeZone, + }); + + @override + State createState() => _VoicesTimePickerState(); +} + +class _VoicesTimePickerState extends State { + late final ScrollController _scrollController; + late final List _timeList; + + final double itemExtent = 40; + + @override + void initState() { + super.initState(); + + _timeList = _generateTimeList(); + _scrollController = ScrollController(); + + final initialSelection = widget.selectedTime; + if (initialSelection != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollToTime(initialSelection); + }); + } + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Material( + type: MaterialType.transparency, + clipBehavior: Clip.hardEdge, + child: Container( + height: 350, + width: 150, + decoration: BoxDecoration( + color: Theme.of(context).colors.elevationsOnSurfaceNeutralLv1Grey, + borderRadius: BorderRadius.circular(20), + ), + child: ListView.builder( + controller: _scrollController, + itemExtent: itemExtent, + itemCount: _timeList.length, + itemBuilder: (context, index) { + final timeOfDay = _timeList[index]; + + return _TimeText( + key: ValueKey(timeOfDay.formatted), + value: timeOfDay, + onTap: widget.onTap, + isSelected: timeOfDay == widget.selectedTime, + timeZone: widget.timeZone, + ); + }, + ), + ), + ); + } + + void _scrollToTime(TimeOfDay value) { + final index = _timeList.indexWhere((e) => e == widget.selectedTime); + + if (index != -1) { + _scrollController.jumpTo(index * itemExtent); + } + } + + List _generateTimeList() { + return [ + for (var hour = 0; hour < 24; hour++) + for (final minute in [0, 30]) TimeOfDay(hour: hour, minute: minute), + ]; + } +} + +class _TimeText extends StatelessWidget { + final ValueChanged onTap; + final TimeOfDay value; + final bool isSelected; + final String? timeZone; + + const _TimeText({ + super.key, + required this.value, + required this.onTap, + this.isSelected = false, + this.timeZone, + }); + + @override + Widget build(BuildContext context) { + final timeZone = this.timeZone; + + return Material( + clipBehavior: Clip.hardEdge, + type: MaterialType.transparency, + child: InkWell( + onTap: () => onTap(value), + child: ColoredBox( + color: !isSelected + ? Colors.transparent + : Theme.of(context).colors.onSurfaceNeutral08!, + child: Padding( + key: key, + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + value.formatted, + style: Theme.of(context).textTheme.bodyLarge, + ), + if (isSelected && timeZone != null) Text(timeZone), + ], + ), + ), + ), + ), + ); + } +} diff --git a/catalyst_voices/apps/voices/lib/widgets/text_field/voices_date_field.dart b/catalyst_voices/apps/voices/lib/widgets/text_field/voices_date_field.dart new file mode 100644 index 00000000000..441c961a2e9 --- /dev/null +++ b/catalyst_voices/apps/voices/lib/widgets/text_field/voices_date_field.dart @@ -0,0 +1,223 @@ +import 'package:catalyst_voices/widgets/buttons/voices_icon_button.dart'; +import 'package:catalyst_voices/widgets/text_field/voices_date_time_text_field.dart'; +import 'package:catalyst_voices/widgets/text_field/voices_text_field.dart'; +import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; +import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:mask_text_input_formatter/mask_text_input_formatter.dart'; + +final class VoicesDateFieldController extends ValueNotifier { + VoicesDateFieldController([super._value]); +} + +class VoicesDateField extends StatefulWidget { + final VoicesDateFieldController? controller; + final ValueChanged? onChanged; + final ValueChanged? onFieldSubmitted; + final VoidCallback? onCalendarTap; + final bool dimBorder; + final BorderRadius borderRadius; + + const VoicesDateField({ + super.key, + this.controller, + this.onChanged, + required this.onFieldSubmitted, + this.onCalendarTap, + this.dimBorder = false, + this.borderRadius = const BorderRadius.all(Radius.circular(16)), + }); + + @override + State createState() => _VoicesDateFieldState(); +} + +class _VoicesDateFieldState extends State { + late final TextEditingController _textEditingController; + late final MaskTextInputFormatter dateFormatter; + + VoicesDateFieldController? _controller; + + VoicesDateFieldController get _effectiveController { + return widget.controller ?? (_controller ??= VoicesDateFieldController()); + } + + String get _pattern => 'dd/MM/yyyy'; + + @override + void initState() { + final initialDate = _effectiveController.value; + final initialText = _convertDateToText(initialDate); + + _textEditingController = TextEditingController(text: initialText); + _textEditingController.addListener(_handleTextChanged); + + _effectiveController.addListener(_handleDateChanged); + + dateFormatter = MaskTextInputFormatter( + mask: _pattern, + filter: { + 'd': RegExp('[0-9]'), + 'M': RegExp('[0-9]'), + 'y': RegExp('[0-9]'), + }, + type: MaskAutoCompletionType.eager, + ); + + super.initState(); + } + + @override + void didUpdateWidget(covariant VoicesDateField oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.controller != oldWidget.controller) { + (oldWidget.controller ?? _controller)?.removeListener(_handleDateChanged); + (widget.controller ?? _controller)?.addListener(_handleDateChanged); + + final date = _effectiveController.value; + _textEditingController.text = _convertDateToText(date); + } + } + + @override + void dispose() { + _textEditingController.dispose(); + + _controller?.dispose(); + _controller = null; + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final onChanged = widget.onChanged; + final onFieldSubmitted = widget.onFieldSubmitted; + + return VoicesDateTimeTextField( + controller: _textEditingController, + onChanged: onChanged != null + ? (value) => onChanged(_convertTextToDate(value)) + : null, + validator: _validate, + hintText: _pattern.toUpperCase(), + onFieldSubmitted: onFieldSubmitted != null + ? (value) => onFieldSubmitted(_convertTextToDate(value)) + : null, + suffixIcon: ExcludeFocus( + child: VoicesIconButton( + onTap: widget.onCalendarTap, + child: VoicesAssets.icons.calendar.buildIcon(), + ), + ), + borderRadius: widget.borderRadius, + dimBorder: widget.dimBorder, + inputFormatters: [dateFormatter], + ); + } + + void _handleTextChanged() { + final date = _convertTextToDate(_textEditingController.text); + if (_effectiveController.value != date) { + _effectiveController.value = date; + } + } + + void _handleDateChanged() { + final text = _convertDateToText(_effectiveController.value); + if (_textEditingController.text != text) { + _textEditingController.text = text; + } + } + + String _convertDateToText(DateTime? value) { + if (value == null) { + return ''; + } + + final day = value.day.toString().padLeft(2, '0'); + final month = value.month.toString().padLeft(2, '0'); + final year = value.year; + + return '$day/$month/$year'; + } + + DateTime? _convertTextToDate(String value) { + if (value.isEmpty) return null; + + final parts = value.split('/'); + if (parts.length != 3) return null; + + try { + final reformatted = '${parts[2]}-${parts[1]}-${parts[0]}'; + final formatDt = DateTime.parse(reformatted); + + if (formatDt.month < 1 || formatDt.month > 12) return null; + if (formatDt.day < 1 || formatDt.day > 31) return null; + if (formatDt.year < 1900 || formatDt.year > 2100) return null; + + return formatDt; + } catch (e) { + return null; + } + } + + VoicesTextFieldValidationResult _validate(String value) { + final today = DateTime.now(); + final maxDate = DateTime(today.year + 1, today.month, today.day); + + if (value.isEmpty) { + return const VoicesTextFieldValidationResult.success(); + } + + final l10n = context.l10n; + + if (value.length != 10) { + return VoicesTextFieldValidationResult.error( + '${l10n.format}: ${_pattern.toUpperCase()}', + ); + } + + final dateRegex = RegExp(r'^(\d{2})/(\d{2})/(\d{4})$'); + if (!dateRegex.hasMatch(value)) { + return VoicesTextFieldValidationResult.error( + '${l10n.format}: ${_pattern.toUpperCase()}', + ); + } + + final parts = value.split('/'); + final reformatted = '${parts[2]}-${parts[1]}-${parts[0]}'; + + // Need this because DateTime.parse accepts out-of-range component values + // and interprets them as overflows into the next larger component + final day = int.parse(parts[0]); + final month = int.parse(parts[1]); + final year = int.parse(parts[2]); + final formatDt = DateTime.parse(reformatted); + if (month < 1 || month > 12) { + return VoicesTextFieldValidationResult.error( + '${l10n.format}: ${_pattern.toUpperCase()}', + ); + } + if (day < 1 || day > 31) { + return VoicesTextFieldValidationResult.error( + l10n.datePickerDaysInMonthError, + ); + } + + final daysInMonth = DateTime(year, month + 1, 0).day; + if (day > daysInMonth) { + return VoicesTextFieldValidationResult.error( + l10n.datePickerDaysInMonthError, + ); + } + if (formatDt.isBefore(today.subtract(const Duration(days: 1))) || + formatDt.isAfter(maxDate)) { + return VoicesTextFieldValidationResult.error( + l10n.datePickerDateRangeError, + ); + } + + return const VoicesTextFieldValidationResult.success(); + } +} diff --git a/catalyst_voices/apps/voices/lib/widgets/text_field/voices_date_time_field.dart b/catalyst_voices/apps/voices/lib/widgets/text_field/voices_date_time_field.dart new file mode 100644 index 00000000000..06d387f5901 --- /dev/null +++ b/catalyst_voices/apps/voices/lib/widgets/text_field/voices_date_time_field.dart @@ -0,0 +1,328 @@ +import 'package:catalyst_voices/widgets/pickers/voices_calendar_picker.dart'; +import 'package:catalyst_voices/widgets/pickers/voices_time_picker.dart'; +import 'package:catalyst_voices/widgets/text_field/voices_date_field.dart'; +import 'package:catalyst_voices/widgets/text_field/voices_time_field.dart'; +import 'package:flutter/material.dart'; + +typedef DateTimeParts = ({DateTime date, TimeOfDay time}); + +enum PickerType { date, time } + +final class VoicesDateTimeFieldController extends ValueNotifier { + VoicesDateTimeFieldController([super._value]); +} + +class VoicesDateTimeField extends StatefulWidget { + final VoicesDateTimeFieldController? controller; + final ValueChanged? onChanged; + final ValueChanged? onFieldSubmitted; + final String? timeZone; + + const VoicesDateTimeField({ + super.key, + this.controller, + this.onChanged, + required this.onFieldSubmitted, + this.timeZone, + }); + + @override + State createState() => _VoicesDateTimeFieldState(); +} + +class _VoicesDateTimeFieldState extends State { + late final VoicesDateFieldController _dateController; + late final VoicesTimeFieldController _timeController; + + final GlobalKey _dateFiledKey = GlobalKey(); + final GlobalKey _timeFieldKey = GlobalKey(); + + VoicesDateTimeFieldController? _controller; + + VoicesDateTimeFieldController get _effectiveController { + return widget.controller ?? + (_controller ??= VoicesDateTimeFieldController()); + } + + OverlayEntry? _overlayEntry; + PickerType? _pickerType; + VoidCallback? _scrollListener; + bool _isDateOverlayOpen = false; + bool _isTimeOverlayOpen = false; + + @override + void initState() { + super.initState(); + + final dateTime = _effectiveController.value; + final parts = dateTime != null ? _convertDateTimeToParts(dateTime) : null; + + _effectiveController.addListener(_handleDateTimeChanged); + + _dateController = VoicesDateFieldController(parts?.date); + _dateController.addListener(_handleDateChanged); + + _timeController = VoicesTimeFieldController(parts?.time); + _timeController.addListener(_handleTimeChanged); + } + + @override + void didUpdateWidget(covariant VoicesDateTimeField oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.controller != oldWidget.controller) { + (oldWidget.controller ?? _controller) + ?.removeListener(_handleDateTimeChanged); + (widget.controller ?? _controller)?.addListener(_handleDateTimeChanged); + } + } + + @override + void dispose() { + _controller?.dispose(); + _controller = null; + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ConstrainedBox( + constraints: const BoxConstraints.tightFor(width: 220), + child: VoicesDateField( + key: _dateFiledKey, + controller: _dateController, + borderRadius: const BorderRadius.horizontal( + left: Radius.circular(16), + ), + dimBorder: _isDateOverlayOpen, + onCalendarTap: _showDatePicker, + onFieldSubmitted: (_) => FocusScope.of(context).nextFocus(), + ), + ), + ConstrainedBox( + constraints: const BoxConstraints.tightFor(width: 200), + child: VoicesTimeField( + key: _timeFieldKey, + controller: _timeController, + borderRadius: const BorderRadius.horizontal( + right: Radius.circular(16), + ), + dimBorder: _isTimeOverlayOpen, + onClockTap: _showTimePicker, + onFieldSubmitted: (_) { + widget.onFieldSubmitted?.call(_effectiveController.value); + }, + timeZone: widget.timeZone, + ), + ), + ], + ); + } + + Future _showDatePicker() async { + final picker = VoicesCalendarDatePicker( + initialDate: _dateController.value, + onDateSelected: (value) { + _removeOverlay(); + _dateController.value = value; + }, + cancelEvent: _removeOverlay, + ); + + final initialPosition = _getRenderBoxOffset(_dateFiledKey); + _pickerType = PickerType.date; + + _showOverlay( + initialPosition: initialPosition, + child: picker, + ); + } + + Future _showTimePicker() async { + final picker = VoicesTimePicker( + onTap: (value) { + _removeOverlay(); + _timeController.value = value; + }, + selectedTime: _timeController.value, + timeZone: widget.timeZone, + ); + + final initialPosition = _getRenderBoxOffset(_timeFieldKey); + _pickerType = PickerType.time; + + _showOverlay( + initialPosition: initialPosition, + child: picker, + ); + } + + void _handleDateTimeChanged() { + final dateTime = _effectiveController.value; + + final parts = dateTime != null ? _convertDateTimeToParts(dateTime) : null; + + if (_dateController.value != parts?.date) { + _dateController.value = parts?.date; + } + + if (_timeController.value != parts?.time) { + _timeController.value = parts?.time; + } + } + + void _handleDateChanged() => _syncControllers(); + + void _handleTimeChanged() => _syncControllers(); + + void _syncControllers() { + final date = _dateController.value; + final time = _timeController.value; + + final dateTime = date != null && time != null + ? DateTime(date.year, date.month, date.day, time.hour, time.minute) + : null; + + if (_effectiveController.value != dateTime) { + _effectiveController.value = dateTime; + } + } + + DateTimeParts? _convertDateTimeToParts(DateTime value) { + final date = DateTime(value.year, value.month, value.day); + final time = TimeOfDay(hour: value.hour, minute: value.minute); + + return (date: date, time: time); + } + + void _showOverlay({ + Offset initialPosition = Offset.zero, + required Widget child, + }) { + FocusScope.of(context).unfocus(); + + if (_pickerType != null) { + _removeOverlay(); + } + + setState(() { + if (_pickerType == PickerType.date) { + _isDateOverlayOpen = true; + } else { + _isTimeOverlayOpen = true; + } + }); + + final overlay = Overlay.of(context, rootOverlay: true); + final scrollPosition = Scrollable.maybeOf(context)?.position; + + _overlayEntry = OverlayEntry( + builder: (context) => Stack( + children: [ + Positioned.fill( + child: MouseRegion( + opaque: false, + hitTestBehavior: HitTestBehavior.translucent, + child: GestureDetector( + onTapDown: (details) async { + final tapPosition = details.globalPosition; + final dateBox = _getRenderBox(_dateFiledKey); + final timeBox = _getRenderBox(_timeFieldKey); + + if (_isBoxTapped(dateBox, tapPosition)) { + return _handleTap(PickerType.date); + } else if (_isBoxTapped(timeBox, tapPosition)) { + return _handleTap(PickerType.time); + } else { + _removeOverlay(); + } + }, + behavior: HitTestBehavior.translucent, + excludeFromSemantics: true, + onPanUpdate: null, + onPanDown: null, + onPanCancel: null, + onPanEnd: null, + onPanStart: null, + child: Container( + color: Colors.transparent, + ), + ), + ), + ), + Positioned( + top: initialPosition.dy + 50 - (scrollPosition?.pixels ?? 0), + left: initialPosition.dx, + child: child, + ), + ], + ), + ); + + if (scrollPosition != null) { + void listener() { + if (_overlayEntry != null) { + _overlayEntry?.markNeedsBuild(); + } + } + + scrollPosition.addListener(listener); + _scrollListener = listener; + } + + overlay.insert(_overlayEntry!); + } + + void _removeOverlay() { + if (_overlayEntry != null) { + setState(() { + if (_pickerType == PickerType.date) { + _isDateOverlayOpen = false; + } else { + _isTimeOverlayOpen = false; + } + }); + + final scrollPosition = Scrollable.maybeOf(context)?.position; + if (scrollPosition != null && _scrollListener != null) { + scrollPosition.removeListener(_scrollListener!); + } + + _overlayEntry?.remove(); + _overlayEntry = null; + _scrollListener = null; + _pickerType = null; + } + } + + Offset _getRenderBoxOffset(GlobalKey key) { + final renderObject = key.currentContext?.findRenderObject(); + if (renderObject is! RenderBox) { + return Offset.zero; + } + + return renderObject.localToGlobal(Offset.zero); + } + + RenderBox? _getRenderBox(GlobalKey key) { + return key.currentContext?.findRenderObject() as RenderBox?; + } + + bool _isBoxTapped(RenderBox? box, Offset tapPosition) { + return box != null && + (box.localToGlobal(Offset.zero) & box.size).contains(tapPosition); + } + + Future _handleTap(PickerType pickerType) async { + _removeOverlay(); + if (pickerType == PickerType.date) { + await _showDatePicker(); + } else { + await _showTimePicker(); + } + } +} diff --git a/catalyst_voices/apps/voices/lib/widgets/text_field/voices_date_time_text_field.dart b/catalyst_voices/apps/voices/lib/widgets/text_field/voices_date_time_text_field.dart new file mode 100644 index 00000000000..b1ad08e0a86 --- /dev/null +++ b/catalyst_voices/apps/voices/lib/widgets/text_field/voices_date_time_text_field.dart @@ -0,0 +1,96 @@ +import 'package:catalyst_voices/widgets/text_field/voices_text_field.dart'; +import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class VoicesDateTimeTextField extends StatelessWidget { + final TextEditingController? controller; + final ValueChanged? onChanged; + final VoicesTextFieldValidator? validator; + final String hintText; + final ValueChanged? onFieldSubmitted; + final Widget suffixIcon; + final bool dimBorder; + final BorderRadius borderRadius; + final List? inputFormatters; + final AutovalidateMode? autovalidateMode; + + const VoicesDateTimeTextField({ + super.key, + this.controller, + this.onChanged, + this.validator, + required this.hintText, + required this.onFieldSubmitted, + required this.suffixIcon, + this.dimBorder = false, + this.borderRadius = const BorderRadius.all(Radius.circular(16)), + this.inputFormatters, + this.autovalidateMode = AutovalidateMode.onUserInteraction, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final textTheme = theme.textTheme; + + final onFieldSubmitted = this.onFieldSubmitted; + + final borderSide = !dimBorder + ? BorderSide( + color: theme.colors.outlineBorderVariant!, + width: 0.75, + ) + : BorderSide( + color: theme.primaryColor, + width: 2, + ); + + return VoicesTextField( + controller: controller, + onChanged: onChanged, + validator: validator, + decoration: VoicesTextFieldDecoration( + suffixIcon: ExcludeFocus(child: suffixIcon), + fillColor: theme.colors.elevationsOnSurfaceNeutralLv1Grey, + filled: true, + enabledBorder: OutlineInputBorder( + borderSide: borderSide, + borderRadius: borderRadius, + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: theme.primaryColor, + width: 2, + ), + borderRadius: borderRadius, + ), + errorBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.error, + width: 2, + ), + borderRadius: borderRadius, + ), + focusedErrorBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.error, + width: 2, + ), + borderRadius: borderRadius, + ), + hintText: hintText, + hintStyle: textTheme.bodyLarge?.copyWith( + color: theme.colors.textDisabled, + ), + errorStyle: textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.error, + ), + errorMaxLines: 3, + ), + onFieldSubmitted: onFieldSubmitted, + inputFormatters: inputFormatters, + autovalidateMode: autovalidateMode, + ); + } +} diff --git a/catalyst_voices/apps/voices/lib/widgets/text_field/voices_text_field.dart b/catalyst_voices/apps/voices/lib/widgets/text_field/voices_text_field.dart index e63a838bf80..40ff880a4d0 100644 --- a/catalyst_voices/apps/voices/lib/widgets/text_field/voices_text_field.dart +++ b/catalyst_voices/apps/voices/lib/widgets/text_field/voices_text_field.dart @@ -71,6 +71,9 @@ class VoicesTextField extends StatefulWidget { /// [TextField.inputFormatters] final List? inputFormatters; + /// [AutovalidateMode] + final AutovalidateMode? autovalidateMode; + const VoicesTextField({ super.key, this.controller, @@ -95,6 +98,7 @@ class VoicesTextField extends StatefulWidget { required this.onFieldSubmitted, this.onSaved, this.inputFormatters, + this.autovalidateMode, }); @override @@ -187,6 +191,7 @@ class _VoicesTextFieldState extends State { onFieldSubmitted: widget.onFieldSubmitted, onSaved: widget.onSaved, inputFormatters: widget.inputFormatters, + autovalidateMode: widget.autovalidateMode, decoration: InputDecoration( filled: widget.decoration?.filled, fillColor: widget.decoration?.fillColor, @@ -249,17 +254,18 @@ class _VoicesTextFieldState extends State { : textTheme.bodySmall! .copyWith(color: theme.colors.textDisabled), hintText: widget.decoration?.hintText, - hintStyle: widget.enabled - ? textTheme.bodyLarge - : textTheme.bodyLarge! - .copyWith(color: theme.colors.textDisabled), + hintStyle: _getHintStyle( + textTheme, + theme, + orDefault: widget.enabled + ? textTheme.bodyLarge + : textTheme.bodyLarge! + .copyWith(color: theme.colors.textDisabled), + ), errorText: widget.decoration?.errorText ?? _validation.errorMessage, errorMaxLines: widget.decoration?.errorMaxLines, - errorStyle: widget.enabled - ? textTheme.bodySmall - : textTheme.bodySmall! - .copyWith(color: theme.colors.textDisabled), + errorStyle: _getErrorStyle(textTheme, theme), prefixIcon: _wrapIconIfExists( widget.decoration?.prefixIcon, const EdgeInsetsDirectional.only(start: 8, end: 4), @@ -322,6 +328,13 @@ class _VoicesTextFieldState extends State { } } + TextStyle? _getHintStyle( + TextTheme textTheme, + ThemeData theme, { + TextStyle? orDefault, + }) => + widget.decoration?.hintStyle ?? orDefault; + Widget? _getStatusSuffixWidget() { final showStatusIcon = widget.decoration?.showStatusSuffixIcon ?? true; if (!showStatusIcon) { @@ -395,6 +408,15 @@ class _VoicesTextFieldState extends State { return customController; } + TextStyle? _getErrorStyle(TextTheme textTheme, ThemeData theme) { + if (widget.decoration?.errorStyle != null) { + return widget.decoration?.errorStyle; + } + return widget.enabled + ? textTheme.bodySmall + : textTheme.bodySmall!.copyWith(color: theme.colors.textDisabled); + } + void _onChanged() { _validate(_obtainController().text); } @@ -446,7 +468,8 @@ class VoicesTextFieldValidationResult with EquatableMixin { /// The validation can be either a success, a warning or an error. final VoicesTextFieldStatus status; - /// The error message to be used in case of a [warning] or an [error]. + /// The error message to be used in case of a [VoicesTextFieldStatus.warning] + /// or an [VoicesTextFieldStatus.error]. final String? errorMessage; const VoicesTextFieldValidationResult({ @@ -459,25 +482,23 @@ class VoicesTextFieldValidationResult with EquatableMixin { 'errorMessage can be only used for warning or error status', ); - @override - List get props => [status, errorMessage]; - /// Returns a successful validation result. /// /// The method was designed to be used as /// [VoicesTextField.validator] param: /// /// ``` - /// validator: VoicesTextFieldValidationResult.success, + /// validator: (value) { + /// return const VoicesTextFieldValidationResult.success(); + /// }, /// ``` /// /// in cases where the text field state is known in advance /// and dynamic validation is not needed. - static VoicesTextFieldValidator success() { - return (_) => const VoicesTextFieldValidationResult( + const VoicesTextFieldValidationResult.success() + : this( status: VoicesTextFieldStatus.success, ); - } /// Returns a warning validation result. /// @@ -485,17 +506,18 @@ class VoicesTextFieldValidationResult with EquatableMixin { /// [VoicesTextField.validator] param: /// /// ``` - /// validator: VoicesTextFieldValidationResult.warning, + /// validator: (value) { + /// return const VoicesTextFieldValidationResult.warning(); + /// }, /// ``` /// /// in cases where the text field state is known in advance /// and dynamic validation is not needed. - static VoicesTextFieldValidator warning([String? message]) { - return (_) => VoicesTextFieldValidationResult( + const VoicesTextFieldValidationResult.warning([String? message]) + : this( status: VoicesTextFieldStatus.warning, errorMessage: message, ); - } /// Returns an error validation result. /// @@ -503,17 +525,21 @@ class VoicesTextFieldValidationResult with EquatableMixin { /// [VoicesTextField.validator] param: /// /// ``` - /// validator: VoicesTextFieldValidationResult.error, + /// validator: (value) { + /// return const VoicesTextFieldValidationResult.error(); + /// }, /// ``` /// /// in cases where the text field state is known in advance /// and dynamic validation is not needed. - static VoicesTextFieldValidator error([String? message]) { - return (_) => VoicesTextFieldValidationResult( + const VoicesTextFieldValidationResult.error([String? message]) + : this( status: VoicesTextFieldStatus.error, errorMessage: message, ); - } + + @override + List get props => [status, errorMessage]; } /// Defines the appearance of the [VoicesTextField]. @@ -565,9 +591,15 @@ class VoicesTextFieldDecoration { /// [InputDecoration.hintText]. final String? hintText; + /// [InputDecoration.hintStyle]. + final TextStyle? hintStyle; + /// [InputDecoration.errorText]. final String? errorText; + /// [InputDecoration.errorStyle] + final TextStyle? errorStyle; + /// [InputDecoration.errorMaxLines]. final int? errorMaxLines; @@ -607,7 +639,9 @@ class VoicesTextFieldDecoration { this.labelText, this.helperText, this.hintText, + this.hintStyle, this.errorText, + this.errorStyle, this.errorMaxLines, this.prefixIcon, this.prefixText, diff --git a/catalyst_voices/apps/voices/lib/widgets/text_field/voices_time_field.dart b/catalyst_voices/apps/voices/lib/widgets/text_field/voices_time_field.dart new file mode 100644 index 00000000000..72ff91f17fb --- /dev/null +++ b/catalyst_voices/apps/voices/lib/widgets/text_field/voices_time_field.dart @@ -0,0 +1,175 @@ +import 'package:catalyst_voices/common/ext/time_of_day_ext.dart'; +import 'package:catalyst_voices/widgets/buttons/voices_icon_button.dart'; +import 'package:catalyst_voices/widgets/text_field/voices_date_time_text_field.dart'; +import 'package:catalyst_voices/widgets/text_field/voices_text_field.dart'; +import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; +import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:mask_text_input_formatter/mask_text_input_formatter.dart'; + +final class VoicesTimeFieldController extends ValueNotifier { + VoicesTimeFieldController([super._value]); +} + +class VoicesTimeField extends StatefulWidget { + final VoicesTimeFieldController? controller; + final ValueChanged? onChanged; + final ValueChanged? onFieldSubmitted; + final VoidCallback? onClockTap; + final bool dimBorder; + final BorderRadius borderRadius; + final String? timeZone; + + const VoicesTimeField({ + super.key, + this.controller, + this.onChanged, + required this.onFieldSubmitted, + this.onClockTap, + this.dimBorder = false, + this.borderRadius = const BorderRadius.all(Radius.circular(16)), + this.timeZone, + }); + + @override + State createState() => _VoicesTimeFieldState(); +} + +class _VoicesTimeFieldState extends State { + late final TextEditingController _textEditingController; + late final MaskTextInputFormatter timeFormatter; + + VoicesTimeFieldController? _controller; + + VoicesTimeFieldController get _effectiveController { + return widget.controller ?? (_controller ??= VoicesTimeFieldController()); + } + + String get _pattern => 'HH:MM'; + String get timeZone => widget.timeZone ?? ''; + + @override + void initState() { + final initialTime = _effectiveController.value; + final initialText = _convertTimeToText(initialTime); + + _textEditingController = TextEditingController(text: initialText); + _textEditingController.addListener(_handleTextChanged); + + _effectiveController.addListener(_handleDateChanged); + + timeFormatter = MaskTextInputFormatter( + mask: _pattern, + filter: { + 'H': RegExp('[0-9]'), + 'M': RegExp('[0-9]'), + }, + type: MaskAutoCompletionType.eager, + ); + + super.initState(); + } + + @override + void didUpdateWidget(covariant VoicesTimeField oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.controller != oldWidget.controller) { + (oldWidget.controller ?? _controller)?.removeListener(_handleDateChanged); + (widget.controller ?? _controller)?.addListener(_handleDateChanged); + + final time = _effectiveController.value; + _textEditingController.text = _convertTimeToText(time); + } + } + + @override + void dispose() { + _textEditingController.dispose(); + + _controller?.dispose(); + _controller = null; + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final onChanged = widget.onChanged; + final onFieldSubmitted = widget.onFieldSubmitted; + + return VoicesDateTimeTextField( + controller: _textEditingController, + onChanged: onChanged != null + ? (value) => onChanged(_convertTextToTime(value)) + : null, + validator: _validate, + hintText: '${_pattern.toUpperCase()} $timeZone', + onFieldSubmitted: onFieldSubmitted != null + ? (value) => onFieldSubmitted(_convertTextToTime(value)) + : null, + suffixIcon: ExcludeFocus( + child: VoicesIconButton( + onTap: widget.onClockTap, + child: VoicesAssets.icons.clock.buildIcon(), + ), + ), + dimBorder: widget.dimBorder, + borderRadius: widget.borderRadius, + inputFormatters: [timeFormatter], + ); + } + + void _handleTextChanged() { + final text = _textEditingController.text; + final time = _convertTextToTime(text); + if (_effectiveController.value != time) { + _effectiveController.value = time; + } + } + + void _handleDateChanged() { + final time = _effectiveController.value; + final text = _convertTimeToText(time); + if (_textEditingController.text != text) { + _textEditingController.text = text; + } + } + + String _convertTimeToText(TimeOfDay? value) { + return value?.formatted ?? ''; + } + + TimeOfDay? _convertTextToTime(String value) { + if (value.isEmpty) return null; + if (_validate(value).status != VoicesTextFieldStatus.success) { + return null; + } + + try { + final parts = value.split(':'); + final rawHours = parts[0]; + final rawMinutes = parts[1]; + + final hour = int.parse(rawHours); + final minute = int.parse(rawMinutes); + + return TimeOfDay(hour: hour, minute: minute); + } catch (e) { + return null; + } + } + + VoicesTextFieldValidationResult _validate(String value) { + if (value.isEmpty) { + return const VoicesTextFieldValidationResult.success(); + } + final l10n = context.l10n; + + final pattern = RegExp(r'^(0?[0-9]|1[0-9]|2[0-3]):([0-5][0-9])$'); + if (!pattern.hasMatch(value)) { + return VoicesTextFieldValidationResult.error('${l10n.format}: $_pattern'); + } + + return const VoicesTextFieldValidationResult.success(); + } +} diff --git a/catalyst_voices/apps/voices/lib/widgets/widgets.dart b/catalyst_voices/apps/voices/lib/widgets/widgets.dart index d0b3f475ece..2cabd912a34 100644 --- a/catalyst_voices/apps/voices/lib/widgets/widgets.dart +++ b/catalyst_voices/apps/voices/lib/widgets/widgets.dart @@ -70,6 +70,7 @@ export 'separators/voices_text_divider.dart'; export 'separators/voices_vertical_divider.dart'; export 'text_field/seed_phrase_field.dart'; export 'text_field/voices_autocomplete.dart'; +export 'text_field/voices_date_time_field.dart'; export 'text_field/voices_email_text_field.dart'; export 'text_field/voices_password_text_field.dart'; export 'text_field/voices_text_field.dart'; diff --git a/catalyst_voices/apps/voices/pubspec.yaml b/catalyst_voices/apps/voices/pubspec.yaml index 27987e9e804..3b00fa0d7e6 100644 --- a/catalyst_voices/apps/voices/pubspec.yaml +++ b/catalyst_voices/apps/voices/pubspec.yaml @@ -54,6 +54,7 @@ dependencies: go_router: ^14.0.2 google_fonts: ^6.2.1 intl: ^0.19.0 + mask_text_input_formatter: ^2.9.0 result_type: ^0.2.0 scrollable_positioned_list: ^0.3.8 sentry_flutter: ^8.8.0 diff --git a/catalyst_voices/apps/voices/test/widgets/text_field/voices_text_field_test.dart b/catalyst_voices/apps/voices/test/widgets/text_field/voices_text_field_test.dart index 7afb9e3c309..11956edf72b 100644 --- a/catalyst_voices/apps/voices/test/widgets/text_field/voices_text_field_test.dart +++ b/catalyst_voices/apps/voices/test/widgets/text_field/voices_text_field_test.dart @@ -106,7 +106,9 @@ void main() { await tester.pumpWidget( _MaterialApp( child: VoicesTextField( - validator: VoicesTextFieldValidationResult.success(), + validator: (value) { + return const VoicesTextFieldValidationResult.success(); + }, onFieldSubmitted: (value) {}, ), ), diff --git a/catalyst_voices/packages/internal/catalyst_voices_localization/lib/l10n/intl_en.arb b/catalyst_voices/packages/internal/catalyst_voices_localization/lib/l10n/intl_en.arb index f0d3896d7ff..21bfa14ee30 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_localization/lib/l10n/intl_en.arb +++ b/catalyst_voices/packages/internal/catalyst_voices_localization/lib/l10n/intl_en.arb @@ -958,6 +958,18 @@ "@reviewRegistrationTransaction": { "description": "A button label to review the registration transaction in wallet detail panel." }, + "format": "Format", + "@format": { + "description": "A label for the format field in the date picker." + }, + "datePickerDateRangeError": "Please select a date within the range of today and one year from today.", + "@datePickerDateRangeError": { + "description": "Error message for the date picker when the selected date is outside the range of today and one year from today." + }, + "datePickerDaysInMonthError": "Entered day exceeds the maximum days for this month.", + "@datePickerDaysInMonthError": { + "description": "Error message for the date picker when the selected day is greater than the maximum days for the selected month." + }, "saveBeforeEditingErrorText": "Please save before editing something else", "mandatoryGuidanceType": "Mandatory", "@mandatoryGuidanceType": { diff --git a/catalyst_voices/utilities/uikit_example/lib/examples/voices_text_field_example.dart b/catalyst_voices/utilities/uikit_example/lib/examples/voices_text_field_example.dart index 059f16ab035..7b97322ed0b 100644 --- a/catalyst_voices/utilities/uikit_example/lib/examples/voices_text_field_example.dart +++ b/catalyst_voices/utilities/uikit_example/lib/examples/voices_text_field_example.dart @@ -85,7 +85,9 @@ class _VoicesTextFieldExampleState extends State { hintText: 'Hint text', ), maxLength: 200, - validator: VoicesTextFieldValidationResult.success(), + validator: (value) { + return const VoicesTextFieldValidationResult.success(); + }, onFieldSubmitted: (value) {}, ), ), @@ -99,8 +101,11 @@ class _VoicesTextFieldExampleState extends State { hintText: 'Hint text', ), maxLength: 200, - validator: - VoicesTextFieldValidationResult.warning('Warning message'), + validator: (value) { + return const VoicesTextFieldValidationResult.warning( + 'Warning message', + ); + }, onFieldSubmitted: (value) {}, ), ), @@ -114,8 +119,11 @@ class _VoicesTextFieldExampleState extends State { hintText: 'Hint text', ), maxLength: 200, - validator: - VoicesTextFieldValidationResult.error('Error message'), + validator: (value) { + return const VoicesTextFieldValidationResult.error( + 'Error message', + ); + }, onFieldSubmitted: (value) {}, ), ), @@ -129,7 +137,9 @@ class _VoicesTextFieldExampleState extends State { hintText: 'Hint text', ), maxLength: 200, - validator: VoicesTextFieldValidationResult.success(), + validator: (value) { + return const VoicesTextFieldValidationResult.success(); + }, enabled: false, onFieldSubmitted: (value) {}, ), @@ -144,8 +154,11 @@ class _VoicesTextFieldExampleState extends State { hintText: 'Hint text', ), maxLength: 200, - validator: - VoicesTextFieldValidationResult.warning('Warning message'), + validator: (value) { + return const VoicesTextFieldValidationResult.warning( + 'Warning message', + ); + }, enabled: false, onFieldSubmitted: (value) {}, ), @@ -160,8 +173,11 @@ class _VoicesTextFieldExampleState extends State { hintText: 'Hint text', ), maxLength: 200, - validator: - VoicesTextFieldValidationResult.error('Error message'), + validator: (value) { + return const VoicesTextFieldValidationResult.error( + 'Error message', + ); + }, enabled: false, onFieldSubmitted: (value) {}, ), diff --git a/catalyst_voices/utilities/uikit_example/lib/generated/assets.gen.dart b/catalyst_voices/utilities/uikit_example/lib/generated/assets.gen.dart deleted file mode 100644 index fea7cf30ebd..00000000000 --- a/catalyst_voices/utilities/uikit_example/lib/generated/assets.gen.dart +++ /dev/null @@ -1,108 +0,0 @@ -/// GENERATED CODE - DO NOT MODIFY BY HAND -/// ***************************************************** -/// FlutterGen -/// ***************************************************** - -// coverage:ignore-file -// ignore_for_file: type=lint -// ignore_for_file: directives_ordering,unnecessary_import,implicit_dynamic_list_literal,deprecated_member_use - -import 'package:flutter/widgets.dart'; - -class $AssetsImagesGen { - const $AssetsImagesGen(); - - /// File path: assets/images/robot_avatar.png - AssetGenImage get robotAvatar => - const AssetGenImage('assets/images/robot_avatar.png'); - - /// List of all assets - List get values => [robotAvatar]; -} - -class UiKitAssets { - UiKitAssets._(); - - static const $AssetsImagesGen images = $AssetsImagesGen(); -} - -class AssetGenImage { - const AssetGenImage( - this._assetName, { - this.size, - this.flavors = const {}, - }); - - final String _assetName; - - final Size? size; - final Set flavors; - - Image image({ - Key? key, - AssetBundle? bundle, - ImageFrameBuilder? frameBuilder, - ImageErrorWidgetBuilder? errorBuilder, - String? semanticLabel, - bool excludeFromSemantics = false, - double? scale, - double? width, - double? height, - Color? color, - Animation? opacity, - BlendMode? colorBlendMode, - BoxFit? fit, - AlignmentGeometry alignment = Alignment.center, - ImageRepeat repeat = ImageRepeat.noRepeat, - Rect? centerSlice, - bool matchTextDirection = false, - bool gaplessPlayback = true, - bool isAntiAlias = false, - String? package, - FilterQuality filterQuality = FilterQuality.low, - int? cacheWidth, - int? cacheHeight, - }) { - return Image.asset( - _assetName, - key: key, - bundle: bundle, - frameBuilder: frameBuilder, - errorBuilder: errorBuilder, - semanticLabel: semanticLabel, - excludeFromSemantics: excludeFromSemantics, - scale: scale, - width: width, - height: height, - color: color, - opacity: opacity, - colorBlendMode: colorBlendMode, - fit: fit, - alignment: alignment, - repeat: repeat, - centerSlice: centerSlice, - matchTextDirection: matchTextDirection, - gaplessPlayback: gaplessPlayback, - isAntiAlias: isAntiAlias, - package: package, - filterQuality: filterQuality, - cacheWidth: cacheWidth, - cacheHeight: cacheHeight, - ); - } - - ImageProvider provider({ - AssetBundle? bundle, - String? package, - }) { - return AssetImage( - _assetName, - bundle: bundle, - package: package, - ); - } - - String get path => _assetName; - - String get keyName => _assetName; -}