diff --git a/modules/legacy/modules/customer_service/lib/src/domain/email_validator.dart b/modules/legacy/modules/customer_service/lib/src/domain/email_validator.dart new file mode 100644 index 00000000..eea33b1b --- /dev/null +++ b/modules/legacy/modules/customer_service/lib/src/domain/email_validator.dart @@ -0,0 +1,17 @@ +import 'package:sf_localizations/sf_localizations.dart'; + +class EmailValidator { + const EmailValidator._(); + + static final _regex = RegExp( + r'^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$', + caseSensitive: false, + ); + + static String? validate(String value) { + final email = value.trim(); + if (email.isEmpty) return I18n.errorEmailRequired; + if (!_regex.hasMatch(email)) return I18n.errorEmailInvalid; + return null; + } +} diff --git a/modules/legacy/modules/customer_service/lib/src/presentation/contact_screen.dart b/modules/legacy/modules/customer_service/lib/src/presentation/contact_screen.dart index 296579dc..da99ff82 100644 --- a/modules/legacy/modules/customer_service/lib/src/presentation/contact_screen.dart +++ b/modules/legacy/modules/customer_service/lib/src/presentation/contact_screen.dart @@ -1,21 +1,78 @@ -import 'package:customer_service/src/presentation/state/contact_view_model.dart'; +import 'package:customer_service/src/domain/email_validator.dart'; +import 'package:customer_service/src/presentation/providers/contact_controller.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:legacy_theme/legacy_theme.dart'; import 'package:legacy_ui/legacy_ui.dart'; import 'package:navigation/navigation.dart'; import 'package:sf_localizations/sf_localizations.dart'; +import 'package:sf_shared/sf_shared.dart'; import 'package:utils/utils.dart'; -class ContactScreen extends ConsumerWidget { +class ContactScreen extends ConsumerStatefulWidget { final NavigationContract navigationContract; const ContactScreen({super.key, required this.navigationContract}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _ContactScreenState(); +} - final viewModel = ref.read(contactViewModelProvider.notifier); +class _ContactScreenState extends ConsumerState { + late final TextEditingController _nameController; + late final TextEditingController _emailController; + late final TextEditingController _subjectController; + late final TextEditingController _bodyController; + String _country = ''; + String _channel = ''; + String? _localError; + + @override + void initState() { + super.initState(); + _nameController = TextEditingController(); + _emailController = TextEditingController(); + _subjectController = TextEditingController(); + _bodyController = TextEditingController(); + } + + @override + void dispose() { + _nameController.dispose(); + _emailController.dispose(); + _subjectController.dispose(); + _bodyController.dispose(); + super.dispose(); + } + + void _onSend() { + final emailError = EmailValidator.validate(_emailController.text); + if (emailError != null) { + setState(() => _localError = emailError); + return; + } + setState(() => _localError = null); + + ref.read(contactControllerProvider.notifier).sendEmail( + country: _country, + channel: _channel, + name: _nameController.text, + email: _emailController.text.trim(), + subject: _subjectController.text, + message: _bodyController.text, + ); + } + + @override + Widget build(BuildContext context) { + ref.listen(contactControllerProvider, (_, next) { + next.showErrorOn(context); + }); + + final isLoading = ref.watch( + contactControllerProvider.select((s) => s.isLoading), + ); return LegacyPageLayout( title: context.translate(I18n.contactTitle), @@ -26,33 +83,76 @@ class ContactScreen extends ConsumerWidget { child: SingleChildScrollView( child: Column( children: [ - const _CountrySection(), + _CountrySection( + onChanged: (value) => setState(() => _country = value), + ), SizedBox(height: SizeUtils.getByScreen(small: 18, big: 17)), - const _ChannelSection(), + _ChannelSection( + onChanged: (value) => setState(() => _channel = value), + ), SizedBox(height: SizeUtils.getByScreen(small: 18, big: 17)), - const _NameSection(), + CustomTextField( + controller: _nameController, + hint: context.translate(I18n.enterName), + ), SizedBox(height: SizeUtils.getByScreen(small: 18, big: 17)), - const _EmailSection(), + CustomTextField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + hint: context.translate(I18n.enterEmail), + ), SizedBox(height: SizeUtils.getByScreen(small: 18, big: 17)), - const _SubjectSection(), + CustomTextField( + controller: _subjectController, + hint: context.translate(I18n.enterSubject), + ), SizedBox(height: SizeUtils.getByScreen(small: 18, big: 17)), - _MessageSection(onSubmit: viewModel.sendEmail), - const _ErrorMessageSection(), + CustomTextField( + controller: _bodyController, + keyboardType: TextInputType.multiline, + hint: context.translate(I18n.enterMessage), + lines: 8, + onSubmitted: (_) => _onSend(), + ), + if (_localError != null) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + context.translate(_localError!), + textAlign: TextAlign.center, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + fontSize: 12, + ), + ), + ), ], ), ), ), - footer: _SendSection(onSend: viewModel.sendEmail), + footer: Container( + padding: SizeUtils.getByScreen( + small: const EdgeInsets.symmetric(horizontal: 38, vertical: 14), + big: const EdgeInsets.symmetric(horizontal: 36, vertical: 12), + ), + child: PrimaryButton( + onPressed: isLoading ? null : _onSend, + text: context.translate(I18n.sendEmail), + color: context.sfColors.legacyPrimary, + ), + ), ); } } -class _CountrySection extends ConsumerWidget { - const _CountrySection(); +class _CountrySection extends StatelessWidget { + final ValueChanged onChanged; + + const _CountrySection({required this.onChanged}); @override - Widget build(BuildContext context, WidgetRef ref) { - final List countries = [ + Widget build(BuildContext context) { + final countries = [ 'EspaƱa', 'Portugal', 'France', @@ -61,149 +161,32 @@ class _CountrySection extends ConsumerWidget { context.translate(I18n.other), ]; - final vm = ref.read(contactViewModelProvider.notifier); - return CustomDropdown( items: countries.map(Text.new).toList(growable: false), - onChanged: (x) { - vm.setCountry(x); - }, + onChanged: (value) => onChanged(value as String), hint: context.translate(I18n.selectCountry), ); } } -class _ChannelSection extends ConsumerWidget { - const _ChannelSection(); +class _ChannelSection extends StatelessWidget { + final ValueChanged onChanged; + + const _ChannelSection({required this.onChanged}); @override - Widget build(BuildContext context, WidgetRef ref) { - final List channels = [ + Widget build(BuildContext context) { + final channels = [ context.translate(I18n.channelOnline), context.translate(I18n.channelAmazon), context.translate(I18n.channelStore), context.translate(I18n.other), ]; - final vm = ref.read(contactViewModelProvider.notifier); - return CustomDropdown( items: channels.map(Text.new).toList(growable: false), - onChanged: (x) { - vm.setChannel(x); - }, + onChanged: (value) => onChanged(value as String), hint: context.translate(I18n.selectChannel), ); } } - -class _NameSection extends ConsumerWidget { - const _NameSection(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final vm = ref.read(contactViewModelProvider.notifier); - - return CustomTextField( - controller: vm.nameController, - hint: context.translate(I18n.enterName), - ); - } -} - -class _EmailSection extends ConsumerWidget { - const _EmailSection(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final vm = ref.read(contactViewModelProvider.notifier); - - return CustomTextField( - controller: vm.emailController, - keyboardType: TextInputType.emailAddress, - hint: context.translate(I18n.enterEmail), - ); - } -} - -class _SubjectSection extends ConsumerWidget { - const _SubjectSection(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final vm = ref.read(contactViewModelProvider.notifier); - - return CustomTextField( - controller: vm.subjectController, - hint: context.translate(I18n.enterSubject), - ); - } -} - -class _MessageSection extends ConsumerWidget { - final VoidCallback onSubmit; - - const _MessageSection({required this.onSubmit}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final vm = ref.read(contactViewModelProvider.notifier); - - return CustomTextField( - controller: vm.bodyController, - keyboardType: TextInputType.multiline, - hint: context.translate(I18n.enterMessage), - lines: 8, - onSubmitted: (_) => onSubmit(), - ); - } -} - -class _ErrorMessageSection extends ConsumerWidget { - const _ErrorMessageSection(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final viewState = ref.watch(contactViewModelProvider); - - if (viewState.errorMessage.isNotEmpty) { - return Column( - children: [ - const SizedBox(height: 4), - Text( - viewState.errorMessage, - textAlign: TextAlign.center, - style: TextStyle( - color: Theme.of(context).colorScheme.error, - fontSize: 12, - ), - ), - ], - ); - } else { - return SizedBox.shrink(); - } - } -} - -class _SendSection extends ConsumerWidget { - final VoidCallback onSend; - - const _SendSection({required this.onSend}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - - return Container( - padding: SizeUtils.getByScreen( - small: EdgeInsets.symmetric(horizontal: 38, vertical: 14), - big: EdgeInsets.symmetric(horizontal: 36, vertical: 12), - ), - child: PrimaryButton( - onPressed: onSend, - text: context.translate(I18n.sendEmail), - color: Theme.of(context).colorScheme.primary, - ), - ); - } -} diff --git a/modules/legacy/modules/customer_service/lib/src/presentation/providers/contact_controller.dart b/modules/legacy/modules/customer_service/lib/src/presentation/providers/contact_controller.dart new file mode 100644 index 00000000..18996fb4 --- /dev/null +++ b/modules/legacy/modules/customer_service/lib/src/presentation/providers/contact_controller.dart @@ -0,0 +1,49 @@ +import 'dart:async'; + +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:sf_shared/sf_shared.dart'; +import 'package:sf_tracking/sf_tracking.dart'; +import 'package:url_launcher/url_launcher.dart'; + +part 'contact_controller.g.dart'; + +@riverpod +class ContactController extends _$ContactController { + @override + FutureOr build() {} + + Future sendEmail({ + required String country, + required String channel, + required String name, + required String email, + required String subject, + required String message, + }) async { + state = const AsyncLoading(); + state = await AsyncValue.guard(() async { + unawaited( + ref.read(sfTrackingProvider).legacySupportContactInitiated( + channel: 'email', + country: country.isEmpty ? 'unknown' : country, + ), + ); + + final body = + 'country:$country\n' + 'Purchase channel:$channel\n' + 'name:$name\n' + 'email:$email\n' + '$message'; + + final url = Uri.parse( + 'mailto:${BrandLinks.supportEmail}' + '?from=$email&subject=$subject&body=$body', + ); + + if (!await launchUrl(url)) { + throw Exception('Could not launch $url'); + } + }); + } +} diff --git a/modules/legacy/modules/customer_service/lib/src/presentation/providers/contact_controller.g.dart b/modules/legacy/modules/customer_service/lib/src/presentation/providers/contact_controller.g.dart new file mode 100644 index 00000000..5ec0adbd --- /dev/null +++ b/modules/legacy/modules/customer_service/lib/src/presentation/providers/contact_controller.g.dart @@ -0,0 +1,55 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'contact_controller.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(ContactController) +const contactControllerProvider = ContactControllerProvider._(); + +final class ContactControllerProvider + extends $AsyncNotifierProvider { + const ContactControllerProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'contactControllerProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$contactControllerHash(); + + @$internal + @override + ContactController create() => ContactController(); +} + +String _$contactControllerHash() => r'5c3ab6fe20cc95013f73788332a2707de7e0f6de'; + +abstract class _$ContactController extends $AsyncNotifier { + FutureOr build(); + @$mustCallSuper + @override + void runBuild() { + build(); + final ref = this.ref as $Ref, void>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, void>, + AsyncValue, + Object?, + Object? + >; + element.handleValue(ref, null); + } +} diff --git a/modules/legacy/modules/customer_service/lib/src/presentation/state/contact_view_model.dart b/modules/legacy/modules/customer_service/lib/src/presentation/state/contact_view_model.dart deleted file mode 100644 index ddd783ac..00000000 --- a/modules/legacy/modules/customer_service/lib/src/presentation/state/contact_view_model.dart +++ /dev/null @@ -1,150 +0,0 @@ -import 'dart:async'; - -import 'package:customer_service/src/presentation/state/contact_view_state.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:sf_localizations/sf_localizations.dart'; -import 'package:sf_shared/sf_shared.dart'; -import 'package:sf_tracking/sf_tracking.dart'; -import 'package:url_launcher/url_launcher.dart'; - -final contactViewModelProvider = - NotifierProvider.autoDispose( - ContactViewModel.new, - ); - -class ContactViewModel extends Notifier { - late final TextEditingController nameController; - late final TextEditingController emailController; - late final TextEditingController subjectController; - late final TextEditingController bodyController; - late final SfTrackingRepository _tracking; - - static final RegExp _emailRegex = RegExp( - r'^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$', - caseSensitive: false, - ); - - @override - ContactViewState build() { - _tracking = ref.read(sfTrackingProvider); - - nameController = TextEditingController(); - emailController = TextEditingController(); - subjectController = TextEditingController(); - bodyController = TextEditingController(); - - nameController.addListener(_onNameChanged); - emailController.addListener(_onEmailChanged); - subjectController.addListener(_onSubjectChanged); - bodyController.addListener(_onBodyChanged); - - ref.onDispose(disposeControllers); - - return const ContactViewState(); - } - - void setCountry(String value) { - if (value == state.country) return; - - state = state.copyWith(country: value, errorMessage: ''); - } - - void setChannel(String value) { - if (value == state.channel) return; - - state = state.copyWith(channel: value, errorMessage: ''); - } - - void _onNameChanged() { - final text = nameController.text; - if (text == state.name) return; - - state = state.copyWith(name: text, errorMessage: ''); - } - - void _onEmailChanged() { - final text = emailController.text; - if (text == state.email) return; - - state = state.copyWith(email: text, errorMessage: ''); - state = state.copyWith(emailError: _emailErrorFor(text)); - } - - bool _isValidEmail(String email) => _emailRegex.hasMatch(email); - - String _emailErrorFor(String value) { - final email = value.trim(); - if (email.isEmpty) return I18n.errorEmailRequired; - if (!_isValidEmail(email)) return I18n.errorEmailInvalid; - return ''; - } - - void _onSubjectChanged() { - final text = subjectController.text; - if (text == state.subject) return; - - state = state.copyWith(subject: text, errorMessage: ''); - } - - void _onBodyChanged() { - final text = bodyController.text; - if (text == state.message) return; - - state = state.copyWith(message: text, errorMessage: ''); - } - - void sendEmail() async { - final receiver = BrandLinks.supportEmail; - - final name = state.name; - final country = state.country; - final channel = state.channel; - final sender = state.email; - final subject = state.subject; - final message = state.message; - - if (sender.isEmpty) { - state = state.copyWith(errorMessage: I18n.errorEmailRequired); - return; - } - if (!_isValidEmail(sender)) { - state = state.copyWith(errorMessage: I18n.errorEmailInvalid); - return; - } - - final body = - 'country:$country\n' - 'Purchase channel:$channel\n' - 'name:$name\n' - 'email:$sender\n' - '$message'; - - final Uri url = Uri.parse( - 'mailto:$receiver?from=$sender&subject=$subject&body=$body', - ); - - unawaited( - _tracking.legacySupportContactInitiated( - channel: 'email', - country: country.isEmpty ? 'unknown' : country, - ), - ); - - if (!await launchUrl(url)) { - throw Exception('Could not launch $url'); - } - } - - void disposeControllers() { - nameController.removeListener(_onNameChanged); - emailController.removeListener(_onEmailChanged); - subjectController.removeListener(_onSubjectChanged); - bodyController.removeListener(_onBodyChanged); - - nameController.dispose(); - emailController.dispose(); - subjectController.dispose(); - bodyController.dispose(); - } -} diff --git a/modules/legacy/modules/customer_service/lib/src/presentation/state/contact_view_state.dart b/modules/legacy/modules/customer_service/lib/src/presentation/state/contact_view_state.dart deleted file mode 100644 index 10ede471..00000000 --- a/modules/legacy/modules/customer_service/lib/src/presentation/state/contact_view_state.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:freezed_annotation/freezed_annotation.dart'; - -part 'contact_view_state.freezed.dart'; - -@freezed -abstract class ContactViewState with _$ContactViewState { - const factory ContactViewState({ - @Default('') String country, - @Default('') String channel, - @Default('') String name, - @Default('') String email, - @Default('') String subject, - @Default('') String message, - @Default('') String errorMessage, - @Default('') String emailError, - }) = _ContactViewState; -} diff --git a/modules/legacy/modules/customer_service/lib/src/presentation/state/contact_view_state.freezed.dart b/modules/legacy/modules/customer_service/lib/src/presentation/state/contact_view_state.freezed.dart deleted file mode 100644 index 74a56960..00000000 --- a/modules/legacy/modules/customer_service/lib/src/presentation/state/contact_view_state.freezed.dart +++ /dev/null @@ -1,292 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND -// coverage:ignore-file -// ignore_for_file: type=lint -// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark - -part of 'contact_view_state.dart'; - -// ************************************************************************** -// FreezedGenerator -// ************************************************************************** - -// dart format off -T _$identity(T value) => value; -/// @nodoc -mixin _$ContactViewState { - - String get country; String get channel; String get name; String get email; String get subject; String get message; String get errorMessage; String get emailError; -/// Create a copy of ContactViewState -/// with the given fields replaced by the non-null parameter values. -@JsonKey(includeFromJson: false, includeToJson: false) -@pragma('vm:prefer-inline') -$ContactViewStateCopyWith get copyWith => _$ContactViewStateCopyWithImpl(this as ContactViewState, _$identity); - - - -@override -bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is ContactViewState&&(identical(other.country, country) || other.country == country)&&(identical(other.channel, channel) || other.channel == channel)&&(identical(other.name, name) || other.name == name)&&(identical(other.email, email) || other.email == email)&&(identical(other.subject, subject) || other.subject == subject)&&(identical(other.message, message) || other.message == message)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage)&&(identical(other.emailError, emailError) || other.emailError == emailError)); -} - - -@override -int get hashCode => Object.hash(runtimeType,country,channel,name,email,subject,message,errorMessage,emailError); - -@override -String toString() { - return 'ContactViewState(country: $country, channel: $channel, name: $name, email: $email, subject: $subject, message: $message, errorMessage: $errorMessage, emailError: $emailError)'; -} - - -} - -/// @nodoc -abstract mixin class $ContactViewStateCopyWith<$Res> { - factory $ContactViewStateCopyWith(ContactViewState value, $Res Function(ContactViewState) _then) = _$ContactViewStateCopyWithImpl; -@useResult -$Res call({ - String country, String channel, String name, String email, String subject, String message, String errorMessage, String emailError -}); - - - - -} -/// @nodoc -class _$ContactViewStateCopyWithImpl<$Res> - implements $ContactViewStateCopyWith<$Res> { - _$ContactViewStateCopyWithImpl(this._self, this._then); - - final ContactViewState _self; - final $Res Function(ContactViewState) _then; - -/// Create a copy of ContactViewState -/// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? country = null,Object? channel = null,Object? name = null,Object? email = null,Object? subject = null,Object? message = null,Object? errorMessage = null,Object? emailError = null,}) { - return _then(_self.copyWith( -country: null == country ? _self.country : country // ignore: cast_nullable_to_non_nullable -as String,channel: null == channel ? _self.channel : channel // ignore: cast_nullable_to_non_nullable -as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable -as String,email: null == email ? _self.email : email // ignore: cast_nullable_to_non_nullable -as String,subject: null == subject ? _self.subject : subject // ignore: cast_nullable_to_non_nullable -as String,message: null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable -as String,errorMessage: null == errorMessage ? _self.errorMessage : errorMessage // ignore: cast_nullable_to_non_nullable -as String,emailError: null == emailError ? _self.emailError : emailError // ignore: cast_nullable_to_non_nullable -as String, - )); -} - -} - - -/// Adds pattern-matching-related methods to [ContactViewState]. -extension ContactViewStatePatterns on ContactViewState { -/// A variant of `map` that fallback to returning `orElse`. -/// -/// It is equivalent to doing: -/// ```dart -/// switch (sealedClass) { -/// case final Subclass value: -/// return ...; -/// case _: -/// return orElse(); -/// } -/// ``` - -@optionalTypeArgs TResult maybeMap(TResult Function( _ContactViewState value)? $default,{required TResult orElse(),}){ -final _that = this; -switch (_that) { -case _ContactViewState() when $default != null: -return $default(_that);case _: - return orElse(); - -} -} -/// A `switch`-like method, using callbacks. -/// -/// Callbacks receives the raw object, upcasted. -/// It is equivalent to doing: -/// ```dart -/// switch (sealedClass) { -/// case final Subclass value: -/// return ...; -/// case final Subclass2 value: -/// return ...; -/// } -/// ``` - -@optionalTypeArgs TResult map(TResult Function( _ContactViewState value) $default,){ -final _that = this; -switch (_that) { -case _ContactViewState(): -return $default(_that);case _: - throw StateError('Unexpected subclass'); - -} -} -/// A variant of `map` that fallback to returning `null`. -/// -/// It is equivalent to doing: -/// ```dart -/// switch (sealedClass) { -/// case final Subclass value: -/// return ...; -/// case _: -/// return null; -/// } -/// ``` - -@optionalTypeArgs TResult? mapOrNull(TResult? Function( _ContactViewState value)? $default,){ -final _that = this; -switch (_that) { -case _ContactViewState() when $default != null: -return $default(_that);case _: - return null; - -} -} -/// A variant of `when` that fallback to an `orElse` callback. -/// -/// It is equivalent to doing: -/// ```dart -/// switch (sealedClass) { -/// case Subclass(:final field): -/// return ...; -/// case _: -/// return orElse(); -/// } -/// ``` - -@optionalTypeArgs TResult maybeWhen(TResult Function( String country, String channel, String name, String email, String subject, String message, String errorMessage, String emailError)? $default,{required TResult orElse(),}) {final _that = this; -switch (_that) { -case _ContactViewState() when $default != null: -return $default(_that.country,_that.channel,_that.name,_that.email,_that.subject,_that.message,_that.errorMessage,_that.emailError);case _: - return orElse(); - -} -} -/// A `switch`-like method, using callbacks. -/// -/// As opposed to `map`, this offers destructuring. -/// It is equivalent to doing: -/// ```dart -/// switch (sealedClass) { -/// case Subclass(:final field): -/// return ...; -/// case Subclass2(:final field2): -/// return ...; -/// } -/// ``` - -@optionalTypeArgs TResult when(TResult Function( String country, String channel, String name, String email, String subject, String message, String errorMessage, String emailError) $default,) {final _that = this; -switch (_that) { -case _ContactViewState(): -return $default(_that.country,_that.channel,_that.name,_that.email,_that.subject,_that.message,_that.errorMessage,_that.emailError);case _: - throw StateError('Unexpected subclass'); - -} -} -/// A variant of `when` that fallback to returning `null` -/// -/// It is equivalent to doing: -/// ```dart -/// switch (sealedClass) { -/// case Subclass(:final field): -/// return ...; -/// case _: -/// return null; -/// } -/// ``` - -@optionalTypeArgs TResult? whenOrNull(TResult? Function( String country, String channel, String name, String email, String subject, String message, String errorMessage, String emailError)? $default,) {final _that = this; -switch (_that) { -case _ContactViewState() when $default != null: -return $default(_that.country,_that.channel,_that.name,_that.email,_that.subject,_that.message,_that.errorMessage,_that.emailError);case _: - return null; - -} -} - -} - -/// @nodoc - - -class _ContactViewState implements ContactViewState { - const _ContactViewState({this.country = '', this.channel = '', this.name = '', this.email = '', this.subject = '', this.message = '', this.errorMessage = '', this.emailError = ''}); - - -@override@JsonKey() final String country; -@override@JsonKey() final String channel; -@override@JsonKey() final String name; -@override@JsonKey() final String email; -@override@JsonKey() final String subject; -@override@JsonKey() final String message; -@override@JsonKey() final String errorMessage; -@override@JsonKey() final String emailError; - -/// Create a copy of ContactViewState -/// with the given fields replaced by the non-null parameter values. -@override @JsonKey(includeFromJson: false, includeToJson: false) -@pragma('vm:prefer-inline') -_$ContactViewStateCopyWith<_ContactViewState> get copyWith => __$ContactViewStateCopyWithImpl<_ContactViewState>(this, _$identity); - - - -@override -bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _ContactViewState&&(identical(other.country, country) || other.country == country)&&(identical(other.channel, channel) || other.channel == channel)&&(identical(other.name, name) || other.name == name)&&(identical(other.email, email) || other.email == email)&&(identical(other.subject, subject) || other.subject == subject)&&(identical(other.message, message) || other.message == message)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage)&&(identical(other.emailError, emailError) || other.emailError == emailError)); -} - - -@override -int get hashCode => Object.hash(runtimeType,country,channel,name,email,subject,message,errorMessage,emailError); - -@override -String toString() { - return 'ContactViewState(country: $country, channel: $channel, name: $name, email: $email, subject: $subject, message: $message, errorMessage: $errorMessage, emailError: $emailError)'; -} - - -} - -/// @nodoc -abstract mixin class _$ContactViewStateCopyWith<$Res> implements $ContactViewStateCopyWith<$Res> { - factory _$ContactViewStateCopyWith(_ContactViewState value, $Res Function(_ContactViewState) _then) = __$ContactViewStateCopyWithImpl; -@override @useResult -$Res call({ - String country, String channel, String name, String email, String subject, String message, String errorMessage, String emailError -}); - - - - -} -/// @nodoc -class __$ContactViewStateCopyWithImpl<$Res> - implements _$ContactViewStateCopyWith<$Res> { - __$ContactViewStateCopyWithImpl(this._self, this._then); - - final _ContactViewState _self; - final $Res Function(_ContactViewState) _then; - -/// Create a copy of ContactViewState -/// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? country = null,Object? channel = null,Object? name = null,Object? email = null,Object? subject = null,Object? message = null,Object? errorMessage = null,Object? emailError = null,}) { - return _then(_ContactViewState( -country: null == country ? _self.country : country // ignore: cast_nullable_to_non_nullable -as String,channel: null == channel ? _self.channel : channel // ignore: cast_nullable_to_non_nullable -as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable -as String,email: null == email ? _self.email : email // ignore: cast_nullable_to_non_nullable -as String,subject: null == subject ? _self.subject : subject // ignore: cast_nullable_to_non_nullable -as String,message: null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable -as String,errorMessage: null == errorMessage ? _self.errorMessage : errorMessage // ignore: cast_nullable_to_non_nullable -as String,emailError: null == emailError ? _self.emailError : emailError // ignore: cast_nullable_to_non_nullable -as String, - )); -} - - -} - -// dart format on diff --git a/modules/legacy/modules/customer_service/pubspec.yaml b/modules/legacy/modules/customer_service/pubspec.yaml index acc5af3d..a71fe84d 100644 --- a/modules/legacy/modules/customer_service/pubspec.yaml +++ b/modules/legacy/modules/customer_service/pubspec.yaml @@ -53,6 +53,7 @@ dependencies: get_it: ^9.0.5 go_router: ^17.0.0 flutter_riverpod: ^3.0.3 + riverpod_annotation: ^3.0.3 freezed_annotation: ^3.1.0 freezed: ^3.2.3 dio: ^5.9.2 @@ -70,6 +71,9 @@ dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^5.0.0 + riverpod_generator: ^3.0.3 + build_runner: ^2.7.1 + mocktail: ^1.0.4 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/modules/legacy/modules/customer_service/test/email_validator_test.dart b/modules/legacy/modules/customer_service/test/email_validator_test.dart new file mode 100644 index 00000000..81556358 --- /dev/null +++ b/modules/legacy/modules/customer_service/test/email_validator_test.dart @@ -0,0 +1,38 @@ +import 'package:customer_service/src/domain/email_validator.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sf_localizations/sf_localizations.dart'; + +void main() { + group('EmailValidator.validate', () { + test('returns errorEmailRequired when value is empty', () { + expect(EmailValidator.validate(''), I18n.errorEmailRequired); + }); + + test('returns errorEmailRequired when value is only whitespace', () { + expect(EmailValidator.validate(' '), I18n.errorEmailRequired); + }); + + test('returns errorEmailInvalid when value lacks @ sign', () { + expect(EmailValidator.validate('notanemail'), I18n.errorEmailInvalid); + }); + + test('returns errorEmailInvalid when domain is missing', () { + expect(EmailValidator.validate('user@'), I18n.errorEmailInvalid); + }); + + test('returns errorEmailInvalid when TLD is missing', () { + expect( + EmailValidator.validate('user@example'), + I18n.errorEmailInvalid, + ); + }); + + test('returns null for a well-formed email', () { + expect(EmailValidator.validate('user@example.com'), isNull); + }); + + test('trims whitespace before validating', () { + expect(EmailValidator.validate(' user@example.com '), isNull); + }); + }); +}